fix(frontend): smooth realtime dashboard refreshes
This commit is contained in:
@@ -4,6 +4,7 @@ import { expect, test, type Page, type Route } from '@playwright/test';
|
|||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||||
|
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||||
const operationNames = (route: Route) =>
|
const operationNames = (route: Route) =>
|
||||||
@@ -17,6 +18,8 @@ type NavigationFixture = {
|
|||||||
npcMode: number;
|
npcMode: number;
|
||||||
generalMeCalls: number;
|
generalMeCalls: number;
|
||||||
operations: string[];
|
operations: string[];
|
||||||
|
generalName?: string;
|
||||||
|
refreshDelayMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyMessages = (permission: number) => ({
|
const emptyMessages = (permission: number) => ({
|
||||||
@@ -34,7 +37,7 @@ const emptyMessages = (permission: number) => ({
|
|||||||
const generalContext = (state: NavigationFixture) => ({
|
const generalContext = (state: NavigationFixture) => ({
|
||||||
general: {
|
general: {
|
||||||
id: 7,
|
id: 7,
|
||||||
name: '메뉴검증장수',
|
name: state.generalName ?? '메뉴검증장수',
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
cityId: 1,
|
cityId: 1,
|
||||||
troopId: 0,
|
troopId: 0,
|
||||||
@@ -124,6 +127,9 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
state.operations.push(...operations);
|
state.operations.push(...operations);
|
||||||
|
if (operations.includes('general.me') && state.generalMeCalls > 0 && state.refreshDelayMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs));
|
||||||
|
}
|
||||||
const results = operations.map((operation) => {
|
const results = operations.map((operation) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
@@ -206,6 +212,36 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const installRealtimeHarness = async (page: Page) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
class TestEventSource extends EventTarget {
|
||||||
|
static latest: TestEventSource | null = null;
|
||||||
|
readonly url: string;
|
||||||
|
|
||||||
|
constructor(url: string | URL) {
|
||||||
|
super();
|
||||||
|
this.url = url.toString();
|
||||||
|
TestEventSource.latest = this;
|
||||||
|
queueMicrotask(() => this.dispatchEvent(new Event('open')));
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (TestEventSource.latest === this) TestEventSource.latest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource });
|
||||||
|
Object.defineProperty(window, '__emitMainRealtime', {
|
||||||
|
configurable: true,
|
||||||
|
value: (type: string, payload: unknown) => {
|
||||||
|
TestEventSource.latest?.dispatchEvent(
|
||||||
|
new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const waitForMain = async (page: Page) => {
|
const waitForMain = async (page: Page) => {
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
@@ -476,3 +512,118 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
|||||||
});
|
});
|
||||||
expect(state.operations).not.toContain('auth.logout');
|
expect(state.operations).not.toContain('auth.logout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('turn realtime refresh keeps rendered panels mounted and patches only changed state', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
refreshDelayMs: 300,
|
||||||
|
};
|
||||||
|
await installRealtimeHarness(page);
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
await expect(page.locator('.general-title')).toContainText('메뉴검증장수');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const general = document.querySelector('[data-main-target="general"]');
|
||||||
|
const city = document.querySelector('[data-main-target="city"]');
|
||||||
|
if (!general || !city) throw new Error('refresh probe targets missing');
|
||||||
|
const probe = {
|
||||||
|
general,
|
||||||
|
city,
|
||||||
|
generalMutations: 0,
|
||||||
|
cityMutations: 0,
|
||||||
|
vueMeasures: [] as string[],
|
||||||
|
};
|
||||||
|
new MutationObserver((records) => (probe.generalMutations += records.length)).observe(general, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
new MutationObserver((records) => (probe.cityMutations += records.length)).observe(city, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, '__mainRefreshProbe', { configurable: true, value: probe });
|
||||||
|
performance.clearMarks();
|
||||||
|
performance.clearMeasures();
|
||||||
|
new PerformanceObserver((entries) => {
|
||||||
|
probe.vueMeasures.push(...entries.getEntries().map((entry) => entry.name));
|
||||||
|
}).observe({ entryTypes: ['measure'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBeforeRefresh = state.generalMeCalls;
|
||||||
|
state.generalName = '부드럽게갱신된장수';
|
||||||
|
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() });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => state.generalMeCalls).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');
|
||||||
|
|
||||||
|
const profile = await page.evaluate(() => {
|
||||||
|
const probe = (
|
||||||
|
window as unknown as {
|
||||||
|
__mainRefreshProbe: {
|
||||||
|
general: Element;
|
||||||
|
city: Element;
|
||||||
|
generalMutations: number;
|
||||||
|
cityMutations: number;
|
||||||
|
vueMeasures: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__mainRefreshProbe;
|
||||||
|
return {
|
||||||
|
generalMounted: probe.general === document.querySelector('[data-main-target="general"]'),
|
||||||
|
cityMounted: probe.city === document.querySelector('[data-main-target="city"]'),
|
||||||
|
generalMutations: probe.generalMutations,
|
||||||
|
cityMutations: probe.cityMutations,
|
||||||
|
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(profile.generalMounted).toBe(true);
|
||||||
|
expect(profile.cityMounted).toBe(true);
|
||||||
|
expect(profile.generalMutations).toBeGreaterThan(0);
|
||||||
|
expect(profile.cityMutations).toBe(0);
|
||||||
|
expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true);
|
||||||
|
expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false);
|
||||||
|
if (autoRefreshArtifactRoot) {
|
||||||
|
await Promise.all([
|
||||||
|
page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }),
|
||||||
|
writeFile(
|
||||||
|
resolve(autoRefreshArtifactRoot, 'profile.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
emittedTurnEvents: 3,
|
||||||
|
refreshRequests: state.generalMeCalls - callsBeforeRefresh,
|
||||||
|
inFlightSkeletons: { general: 0, city: 0 },
|
||||||
|
...profile,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import './assets/main.css';
|
|||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// Vue emits component init/render/patch measures in development builds. This
|
||||||
|
// keeps realtime refresh profiling available in Chromium DevTools without
|
||||||
|
// adding production runtime work.
|
||||||
|
app.config.performance = import.meta.env.DEV;
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { RealtimeEvent } from '@sammo-ts/common';
|
|||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useMapViewerStore } from './mapViewer';
|
import { useMapViewerStore } from './mapViewer';
|
||||||
import { useSessionStore } from './session';
|
import { useSessionStore } from './session';
|
||||||
|
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
|
||||||
|
import { structurallyShare } from '../utils/structuralShare';
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
@@ -18,6 +20,7 @@ const resolveErrorMessage = (value: unknown): string => {
|
|||||||
|
|
||||||
export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||||
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
type PresentGeneralContext = NonNullable<GeneralContext>;
|
||||||
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
|
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
|
||||||
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
|
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
|
||||||
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
||||||
@@ -30,13 +33,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const refreshing = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const recordsError = ref<string | null>(null);
|
const recordsError = ref<string | null>(null);
|
||||||
const frontStatusError = ref<string | null>(null);
|
const frontStatusError = ref<string | null>(null);
|
||||||
const realtimeEnabled = ref(true);
|
const realtimeEnabled = ref(true);
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||||
|
|
||||||
const generalContext = ref<GeneralContext | null>(null);
|
const general = ref<PresentGeneralContext['general'] | null>(null);
|
||||||
|
const city = ref<PresentGeneralContext['city'] | null>(null);
|
||||||
|
const nation = ref<PresentGeneralContext['nation'] | null>(null);
|
||||||
const lobbyInfo = ref<LobbyInfo | null>(null);
|
const lobbyInfo = ref<LobbyInfo | null>(null);
|
||||||
const worldMap = ref<WorldMapResult | null>(null);
|
const worldMap = ref<WorldMapResult | null>(null);
|
||||||
const mapLayout = ref<MapLayout | null>(null);
|
const mapLayout = ref<MapLayout | null>(null);
|
||||||
@@ -54,14 +60,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
let lastGeneralRecordId = 0;
|
let lastGeneralRecordId = 0;
|
||||||
let lastWorldHistoryId = 0;
|
let lastWorldHistoryId = 0;
|
||||||
let recordGeneralId: number | null = null;
|
let recordGeneralId: number | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
const messageDraftText = ref('');
|
const messageDraftText = ref('');
|
||||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
let initializedMailboxGeneralId: number | null = null;
|
let initializedMailboxGeneralId: number | null = null;
|
||||||
|
|
||||||
const general = computed(() => generalContext.value?.general ?? null);
|
|
||||||
const city = computed(() => generalContext.value?.city ?? null);
|
|
||||||
const nation = computed(() => generalContext.value?.nation ?? null);
|
|
||||||
const generalId = computed(() => general.value?.id ?? null);
|
const generalId = computed(() => general.value?.id ?? null);
|
||||||
const nationId = computed(() => nation.value?.id ?? null);
|
const nationId = computed(() => nation.value?.id ?? null);
|
||||||
const mapViewer = useMapViewerStore();
|
const mapViewer = useMapViewerStore();
|
||||||
@@ -114,7 +118,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
options: MailboxOption[];
|
options: MailboxOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ownNationId = general.value?.nationId ?? 0;
|
const ownNationId = nationId.value ?? 0;
|
||||||
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
|
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
|
||||||
const permission = messages.value?.permission ?? -1;
|
const permission = messages.value?.permission ?? -1;
|
||||||
const contacts = messageContacts.value?.nation ?? [];
|
const contacts = messageContacts.value?.nation ?? [];
|
||||||
@@ -204,7 +208,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateFrontStatus = (nextStatus: FrontStatus) => {
|
const updateFrontStatus = (nextStatus: FrontStatus) => {
|
||||||
frontStatus.value = nextStatus;
|
frontStatus.value = structurallyShare(frontStatus.value, nextStatus);
|
||||||
const latestVote = nextStatus.latestVote;
|
const latestVote = nextStatus.latestVote;
|
||||||
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
|
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
|
||||||
surveyNotice.value = null;
|
surveyNotice.value = null;
|
||||||
@@ -244,25 +248,29 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
surveyNotice.value = null;
|
surveyNotice.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const refreshMainData = async () => {
|
||||||
if (loading.value) {
|
const isInitialLoad = !initialized;
|
||||||
return;
|
if (isInitialLoad) {
|
||||||
|
loading.value = true;
|
||||||
|
} else {
|
||||||
|
refreshing.value = true;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
error.value = null;
|
||||||
recordsError.value = null;
|
recordsError.value = null;
|
||||||
frontStatusError.value = null;
|
frontStatusError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await trpc.general.me.query();
|
const context = await trpc.general.me.query();
|
||||||
generalContext.value = context;
|
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
general.value = null;
|
||||||
|
city.value = null;
|
||||||
|
nation.value = null;
|
||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedGeneralRevision.value = 0;
|
reservedGeneralRevision.value = 0;
|
||||||
boardAccess.value = null;
|
boardAccess.value = null;
|
||||||
resetRecentRecords(null);
|
resetRecentRecords(null);
|
||||||
loading.value = false;
|
initialized = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,19 +317,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
frontStatusPromise,
|
frontStatusPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mapLayout.value = layout;
|
general.value = structurallyShare(general.value, context.general);
|
||||||
lobbyInfo.value = lobby;
|
city.value = structurallyShare(city.value, context.city);
|
||||||
worldMap.value = map;
|
nation.value = structurallyShare(nation.value, context.nation);
|
||||||
commandTable.value = commands;
|
mapLayout.value = structurallyShare(mapLayout.value, layout);
|
||||||
messages.value = messageData;
|
lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby);
|
||||||
messageContacts.value = contacts;
|
worldMap.value = structurallyShare(worldMap.value, map);
|
||||||
boardAccess.value = access;
|
commandTable.value = structurallyShare(commandTable.value, commands);
|
||||||
reservedGeneralTurns.value = generalTurns.turns;
|
messages.value = structurallyShare(messages.value, messageData);
|
||||||
|
messageContacts.value = structurallyShare(messageContacts.value, contacts);
|
||||||
|
boardAccess.value = structurallyShare(boardAccess.value, access);
|
||||||
|
reservedGeneralTurns.value = structurallyShare<unknown>(
|
||||||
|
reservedGeneralTurns.value,
|
||||||
|
generalTurns.turns
|
||||||
|
) as ReservedTurnView[];
|
||||||
reservedGeneralRevision.value = generalTurns.revision;
|
reservedGeneralRevision.value = generalTurns.revision;
|
||||||
if (records) {
|
if (records) {
|
||||||
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
|
globalRecords.value = structurallyShare(
|
||||||
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
|
globalRecords.value,
|
||||||
worldHistory.value = mergeRecentRecords(worldHistory.value, records.history);
|
mergeRecentRecords(globalRecords.value, records.global)
|
||||||
|
);
|
||||||
|
generalRecords.value = structurallyShare(
|
||||||
|
generalRecords.value,
|
||||||
|
mergeRecentRecords(generalRecords.value, records.general)
|
||||||
|
);
|
||||||
|
worldHistory.value = structurallyShare(
|
||||||
|
worldHistory.value,
|
||||||
|
mergeRecentRecords(worldHistory.value, records.history)
|
||||||
|
);
|
||||||
lastGeneralRecordId = Math.max(
|
lastGeneralRecordId = Math.max(
|
||||||
lastGeneralRecordId,
|
lastGeneralRecordId,
|
||||||
records.global[0]?.id ?? 0,
|
records.global[0]?.id ?? 0,
|
||||||
@@ -336,20 +359,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||||
initializedMailboxGeneralId = id;
|
initializedMailboxGeneralId = id;
|
||||||
}
|
}
|
||||||
|
initialized = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (isInitialLoad) {
|
||||||
|
loading.value = false;
|
||||||
|
} else {
|
||||||
|
refreshing.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshQueue = createLatestRefreshQueue(refreshMainData);
|
||||||
|
const loadMainData = () => refreshQueue.request();
|
||||||
|
|
||||||
const refreshMessages = async () => {
|
const refreshMessages = async () => {
|
||||||
const id = generalId.value;
|
const id = generalId.value;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
messages.value = await trpc.messages.getRecent.query({ generalId: id });
|
messages.value = structurallyShare(messages.value, await trpc.messages.getRecent.query({ generalId: id }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
}
|
}
|
||||||
@@ -695,12 +726,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
refreshing,
|
||||||
error,
|
error,
|
||||||
recordsError,
|
recordsError,
|
||||||
frontStatusError,
|
frontStatusError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
realtimeStatus,
|
realtimeStatus,
|
||||||
generalContext,
|
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type LatestRefreshQueue = {
|
||||||
|
request: () => Promise<void>;
|
||||||
|
isRunning: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesces bursts while guaranteeing one final refresh after an in-flight run.
|
||||||
|
* This matches realtime state semantics: intermediate turn notifications can be
|
||||||
|
* skipped, but the newest committed server state must never be lost.
|
||||||
|
*/
|
||||||
|
export const createLatestRefreshQueue = (refresh: () => Promise<void>): LatestRefreshQueue => {
|
||||||
|
let active: Promise<void> | null = null;
|
||||||
|
let refreshAgain = false;
|
||||||
|
|
||||||
|
const request = (): Promise<void> => {
|
||||||
|
if (active) {
|
||||||
|
refreshAgain = true;
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
do {
|
||||||
|
refreshAgain = false;
|
||||||
|
await refresh();
|
||||||
|
} while (refreshAgain);
|
||||||
|
};
|
||||||
|
|
||||||
|
active = run().finally(() => {
|
||||||
|
active = null;
|
||||||
|
});
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
isRunning: () => active !== null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuses every unchanged branch from the current snapshot.
|
||||||
|
*
|
||||||
|
* tRPC returns a fresh object graph for every request. Assigning that graph
|
||||||
|
* directly makes Vue notify consumers even when their slice did not change.
|
||||||
|
* Structural sharing keeps that notification boundary aligned with actual
|
||||||
|
* value changes without maintaining a field-by-field event routing table.
|
||||||
|
*/
|
||||||
|
export const structurallyShare = <T>(current: T, incoming: T): T => {
|
||||||
|
if (Object.is(current, incoming)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current instanceof Date && incoming instanceof Date) {
|
||||||
|
return (current.getTime() === incoming.getTime() ? current : incoming) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(current) && Array.isArray(incoming)) {
|
||||||
|
if (current.length !== incoming.length) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
let unchanged = true;
|
||||||
|
const shared = incoming.map((value, index) => {
|
||||||
|
const next = structurallyShare(current[index], value);
|
||||||
|
unchanged &&= Object.is(next, current[index]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return (unchanged ? current : shared) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(current) && isRecord(incoming)) {
|
||||||
|
const currentKeys = Object.keys(current);
|
||||||
|
const incomingKeys = Object.keys(incoming);
|
||||||
|
if (currentKeys.length !== incomingKeys.length || currentKeys.some((key) => !(key in incoming))) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unchanged = true;
|
||||||
|
const shared: Record<string, unknown> = {};
|
||||||
|
for (const key of incomingKeys) {
|
||||||
|
const next = structurallyShare(current[key], incoming[key]);
|
||||||
|
shared[key] = next;
|
||||||
|
unchanged &&= Object.is(next, current[key]);
|
||||||
|
}
|
||||||
|
return (unchanged ? current : shared) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming;
|
||||||
|
};
|
||||||
@@ -31,6 +31,7 @@ const npcMode = ref(0);
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
|
refreshing,
|
||||||
error,
|
error,
|
||||||
recordsError,
|
recordsError,
|
||||||
frontStatusError,
|
frontStatusError,
|
||||||
@@ -158,7 +159,13 @@ watch(
|
|||||||
>
|
>
|
||||||
실시간 동기화: {{ realtimeLabel }}
|
실시간 동기화: {{ realtimeLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button class="game-shell__action game-shell__action--navigation" type="button" @click="loadMainData">
|
<button
|
||||||
|
class="game-shell__action game-shell__action--navigation"
|
||||||
|
type="button"
|
||||||
|
:disabled="refreshing"
|
||||||
|
:aria-busy="refreshing"
|
||||||
|
@click="loadMainData"
|
||||||
|
>
|
||||||
갱 신
|
갱 신
|
||||||
</button>
|
</button>
|
||||||
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
|
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createLatestRefreshQueue } from '../src/utils/latestRefreshQueue.ts';
|
||||||
|
|
||||||
|
void test('coalesces an event burst into one final refresh without losing it', async () => {
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
let runs = 0;
|
||||||
|
const queue = createLatestRefreshQueue(async () => {
|
||||||
|
runs += 1;
|
||||||
|
await new Promise<void>((resolve) => releases.push(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = queue.request();
|
||||||
|
assert.equal(queue.isRunning(), true);
|
||||||
|
const second = queue.request();
|
||||||
|
const third = queue.request();
|
||||||
|
assert.equal(second, first);
|
||||||
|
assert.equal(third, first);
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
|
||||||
|
releases.shift()?.();
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(runs, 2);
|
||||||
|
|
||||||
|
releases.shift()?.();
|
||||||
|
await first;
|
||||||
|
assert.equal(queue.isRunning(), false);
|
||||||
|
assert.equal(runs, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { structurallyShare } from '../src/utils/structuralShare.ts';
|
||||||
|
|
||||||
|
void test('reuses a completely unchanged tRPC snapshot', () => {
|
||||||
|
const current = {
|
||||||
|
general: { id: 7, name: '장수' },
|
||||||
|
records: [{ id: 3, text: '기록' }],
|
||||||
|
createdAt: new Date('2026-08-07T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
const incoming = {
|
||||||
|
general: { id: 7, name: '장수' },
|
||||||
|
records: [{ id: 3, text: '기록' }],
|
||||||
|
createdAt: new Date('2026-08-07T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(structurallyShare(current, incoming), current);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('replaces only changed branches and preserves sibling identities', () => {
|
||||||
|
const current = {
|
||||||
|
general: { id: 7, name: '이전 이름' },
|
||||||
|
city: { id: 1, name: '업' },
|
||||||
|
records: [{ id: 3, text: '기록' }],
|
||||||
|
};
|
||||||
|
const incoming = {
|
||||||
|
general: { id: 7, name: '새 이름' },
|
||||||
|
city: { id: 1, name: '업' },
|
||||||
|
records: [{ id: 3, text: '기록' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const shared = structurallyShare(current, incoming);
|
||||||
|
assert.notEqual(shared, current);
|
||||||
|
assert.notEqual(shared.general, current.general);
|
||||||
|
assert.deepEqual(shared.general, incoming.general);
|
||||||
|
assert.equal(shared.city, current.city);
|
||||||
|
assert.equal(shared.records, current.records);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user