From d6bab7e4bf0b8ac51917f1853685d80694162155 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 9 Aug 2026 13:24:36 +0000 Subject: [PATCH] fix(game-frontend): bound realtime dashboard refreshes --- app/game-frontend/e2e/mainNavigation.spec.ts | 46 ++++++---- app/game-frontend/src/stores/mainDashboard.ts | 66 +++++++++++++-- .../src/utils/rateLimitedRefreshQueue.ts | 69 +++++++++++++++ app/game-frontend/src/views/MainView.vue | 7 +- .../test/rateLimitedRefreshQueue.test.ts | 83 +++++++++++++++++++ 5 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 app/game-frontend/src/utils/rateLimitedRefreshQueue.ts create mode 100644 app/game-frontend/test/rateLimitedRefreshQueue.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0f961f0d..0a938592 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -239,6 +239,10 @@ const installRealtimeHarness = async (page: Page) => { ); }, }); + Object.defineProperty(window, '__hasMainRealtime', { + configurable: true, + value: () => TestEventSource.latest !== null, + }); }); }; @@ -513,7 +517,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return expect(state.operations).not.toContain('auth.logout'); }); -test('turn realtime refresh keeps rendered panels mounted and patches only changed state', async ({ page }) => { +test('turn realtime refresh is rate limited, patches in place, and stops after leaving main', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -564,23 +568,19 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang await page.evaluate(() => { const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; - emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); - emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); - emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); + for (let index = 0; index < 100; index += 1) { + emit('turnCompleted', { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z' }); + } }); - await expect.poll(() => state.generalMeCalls).toBe(callsBeforeRefresh + 1); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(state.generalMeCalls).toBe(callsBeforeRefresh); + await expect.poll(() => state.generalMeCalls, { timeout: 7_000 }).toBe(callsBeforeRefresh + 1); await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); - await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'true'); - if (autoRefreshArtifactRoot) { - await mkdir(resolve(autoRefreshArtifactRoot), { recursive: true }); - await page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-in-flight.png'), fullPage: true }); - } - - await expect.poll(() => state.generalMeCalls, { timeout: 5_000 }).toBe(callsBeforeRefresh + 2); - await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수'); await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false'); + expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1); + await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수'); const profile = await page.evaluate(() => { const probe = ( @@ -615,7 +615,7 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang resolve(autoRefreshArtifactRoot, 'profile.json'), `${JSON.stringify( { - emittedTurnEvents: 3, + emittedTurnEvents: 100, refreshRequests: state.generalMeCalls - callsBeforeRefresh, inFlightSkeletons: { general: 0, city: 0 }, ...profile, @@ -626,4 +626,22 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang ), ]); } + + await page.locator(`a[href="${basePath}/board"]`).first().click(); + await page.waitForURL(`**${basePath}/board`); + expect( + await page.evaluate( + () => + (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() + ) + ).toBe(false); + const callsAfterLeavingMain = state.generalMeCalls; + await page.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( + 'turnCompleted', + { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z' } + ); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(state.generalMeCalls).toBe(callsAfterLeavingMain); }); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 0e6cb5b7..47ad8bda 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -6,8 +6,11 @@ import { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; import { useSessionStore } from './session'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; +import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { structurallyShare } from '../utils/structuralShare'; +const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; + const resolveErrorMessage = (value: unknown): string => { if (value instanceof Error) { return value.message; @@ -39,6 +42,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const frontStatusError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); + const realtimeActive = ref(false); const general = ref(null); const city = ref(null); @@ -373,6 +377,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const refreshQueue = createLatestRefreshQueue(refreshMainData); const loadMainData = () => refreshQueue.request(); + const realtimeRefreshQueue = createRateLimitedRefreshQueue(() => refreshQueue.request(), { + minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS, + }); const refreshMessages = async () => { const id = generalId.value; @@ -593,6 +600,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { let realtimeSource: EventSource | null = null; let realtimeToken: string | null = null; + let visibilityListenerInstalled = false; const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_')); @@ -663,7 +671,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (typeof window === 'undefined') { return; } - if (!realtimeEnabled.value || !session.isReady || !session.hasGeneral) { + if ( + !realtimeActive.value || + document.visibilityState === 'hidden' || + !realtimeEnabled.value || + !session.isReady || + !session.hasGeneral + ) { return; } const token = await ensureAccessToken(); @@ -688,7 +702,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; }); source.addEventListener('turnCompleted', () => { - void loadMainData(); + realtimeRefreshQueue.request(); }); source.addEventListener('messageCreated', (event) => { const payload = parseRealtimePayload(event); @@ -706,9 +720,48 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); }; + const handleVisibilityChange = () => { + if (!realtimeActive.value) return; + if (document.visibilityState === 'hidden') { + realtimeRefreshQueue.cancelPending(); + closeRealtimeSource(); + realtimeStatus.value = 'idle'; + return; + } + realtimeRefreshQueue.beginCooldown(); + void connectRealtime(); + realtimeRefreshQueue.request(); + }; + + const startRealtime = () => { + if (typeof window === 'undefined' || realtimeActive.value) return; + realtimeActive.value = true; + realtimeRefreshQueue.beginCooldown(); + if (!visibilityListenerInstalled) { + document.addEventListener('visibilitychange', handleVisibilityChange); + visibilityListenerInstalled = true; + } + }; + + const stopRealtime = () => { + realtimeActive.value = false; + realtimeRefreshQueue.cancelPending(); + closeRealtimeSource(); + if (visibilityListenerInstalled) { + document.removeEventListener('visibilitychange', handleVisibilityChange); + visibilityListenerInstalled = false; + } + realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; + }; + watch( - () => [realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken], - ([enabled, ready, hasGeneral]) => { + () => [realtimeActive.value, realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken], + ([active, enabled, ready, hasGeneral]) => { + if (!active) { + closeRealtimeSource(); + realtimeStatus.value = enabled ? 'idle' : 'paused'; + return; + } if (!enabled) { closeRealtimeSource(); realtimeStatus.value = 'paused'; @@ -720,8 +773,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return; } void connectRealtime(); - }, - { immediate: true } + } ); return { @@ -755,6 +807,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { statusLine, realtimeLabel, setRealtimeEnabled, + startRealtime, + stopRealtime, dismissSurveyNotice, loadMainData, refreshMessages, diff --git a/app/game-frontend/src/utils/rateLimitedRefreshQueue.ts b/app/game-frontend/src/utils/rateLimitedRefreshQueue.ts new file mode 100644 index 00000000..bfe5be7e --- /dev/null +++ b/app/game-frontend/src/utils/rateLimitedRefreshQueue.ts @@ -0,0 +1,69 @@ +export type RateLimitedRefreshQueue = { + request: () => void; + beginCooldown: () => void; + cancelPending: () => void; + isRunning: () => boolean; +}; + +type TimerHandle = ReturnType; + +export type RateLimitedRefreshQueueOptions = { + minIntervalMs: number; + now?: () => number; + setTimer?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimer?: (timer: TimerHandle) => void; +}; + +/** + * Keeps a sustained notification stream from turning into a sustained request + * stream. One trailing refresh is retained, while starts are bounded by the + * configured interval. + */ +export const createRateLimitedRefreshQueue = ( + refresh: () => Promise, + options: RateLimitedRefreshQueueOptions +): RateLimitedRefreshQueue => { + const minIntervalMs = Math.max(0, options.minIntervalMs); + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer)); + + let active = false; + let pending = false; + let timer: TimerHandle | null = null; + let lastStartedAt = Number.NEGATIVE_INFINITY; + + const schedule = () => { + if (!pending || active || timer) return; + const delayMs = Math.max(0, lastStartedAt + minIntervalMs - now()); + timer = setTimer(() => { + timer = null; + if (!pending) return; + pending = false; + active = true; + lastStartedAt = now(); + void refresh().finally(() => { + active = false; + schedule(); + }); + }, delayMs); + }; + + return { + request: () => { + pending = true; + schedule(); + }, + beginCooldown: () => { + lastStartedAt = now(); + }, + cancelPending: () => { + pending = false; + if (timer) { + clearTimer(timer); + timer = null; + } + }, + isRunning: () => active || timer !== null, + }; +}; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 4934c747..4bf9ab99 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,5 +1,5 @@