diff --git a/src/content/market/index.ts b/src/content/market/index.ts index e4e1b77..96587ba 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -225,11 +225,13 @@ export function createMarketController(options: CreateMarketControllerOptions) { }); let activeSort: MarketSortState | undefined; let isDisposed = false; - let isSyncRunning = false; - let isSyncScheduled = false; let lastKnownPageSignature = ""; let needsResync = false; let scheduledSyncTimeoutId: number | null = null; + let activeSyncPromise: Promise | null = null; + let scheduledSyncPromise: Promise | null = null; + let resolveScheduledSync: (() => void) | null = null; + let rejectScheduledSync: ((reason?: unknown) => void) | null = null; const selectedAuthorIds = new Set(); let toolbar: ReturnType | undefined; let cachedFavoritesState: FavoritesStateV1 | undefined; @@ -533,7 +535,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { const ready = (async () => { await runSyncCycle(); await refreshFavorites(); + const initialScheduledSync = scheduledSyncPromise; await waitForDomSettled(); + await (initialScheduledSync ?? activeSyncPromise ?? scheduledSyncPromise); })(); return { @@ -544,6 +548,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { options.window.clearTimeout(scheduledSyncTimeoutId); scheduledSyncTimeoutId = null; } + resolveScheduledSync?.(); + scheduledSyncPromise = null; + resolveScheduledSync = null; + rejectScheduledSync = null; disposeFavoriteRowPickers(options.document); favoritesDrawer.dispose(); }, @@ -1783,29 +1791,42 @@ export function createMarketController(options: CreateMarketControllerOptions) { }; } - function scheduleSync(): void { + function scheduleSync(): Promise { if (isDisposed) { - return; + return Promise.resolve(); } - if (isSyncRunning) { + if (activeSyncPromise) { needsResync = true; - return; + return activeSyncPromise; } - if (isSyncScheduled) { - return; + if (scheduledSyncPromise) { + return scheduledSyncPromise; } - isSyncScheduled = true; + const nextScheduledSyncPromise = new Promise((resolve, reject) => { + resolveScheduledSync = resolve; + rejectScheduledSync = reject; + }); + scheduledSyncPromise = nextScheduledSyncPromise; + void nextScheduledSyncPromise.catch(() => undefined); scheduledSyncTimeoutId = options.window.setTimeout(() => { + const resolve = resolveScheduledSync; + const reject = rejectScheduledSync; scheduledSyncTimeoutId = null; - isSyncScheduled = false; + scheduledSyncPromise = null; + resolveScheduledSync = null; + rejectScheduledSync = null; if (isDisposed) { + resolve?.(); return; } - void runSyncCycle(); + const syncPromise = runSyncCycle(); + void syncPromise.catch(() => undefined); + void syncPromise.then(resolve, reject); }, 0); + return nextScheduledSyncPromise; } function runWithoutMutationSync(callback: () => void): void { @@ -1832,36 +1853,52 @@ export function createMarketController(options: CreateMarketControllerOptions) { }); } - async function runSyncCycle(): Promise { + function runSyncCycle(): Promise { if (isDisposed) { - return; + return Promise.resolve(); } - if (isSyncRunning) { + if (activeSyncPromise) { needsResync = true; + return activeSyncPromise; + } + + const syncPromise = runSingleSyncCycle(); + activeSyncPromise = syncPromise; + void syncPromise.then( + () => { + completeSyncCycle(syncPromise); + }, + () => { + completeSyncCycle(syncPromise); + } + ); + return syncPromise; + } + + function completeSyncCycle(syncPromise: Promise): void { + if (activeSyncPromise !== syncPromise) { return; } - isSyncRunning = true; - try { - toolbar = ensurePluginToolbar(options.document, toolbarHandlers); - await hydrateCurrentPage(); - if (needsFavoritesRefresh) { - needsFavoritesRefresh = false; - await refreshFavorites(); - } - applyCurrentView(); - lastKnownPageSignature = readMarketPageSignature(options.document); - } finally { - isSyncRunning = false; - if (isDisposed) { - return; - } - if (needsResync) { - needsResync = false; - scheduleSync(); - } + activeSyncPromise = null; + if (isDisposed || !needsResync) { + return; } + + needsResync = false; + scheduleSync(); + } + + async function runSingleSyncCycle(): Promise { + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); + await hydrateCurrentPage(); + if (needsFavoritesRefresh) { + needsFavoritesRefresh = false; + await refreshFavorites(); + } + applyCurrentView(); + lastKnownPageSignature = readMarketPageSignature(options.document); } } diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 45c8ab8..84cdf32 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -4975,6 +4975,85 @@ describe("market-content-entry", () => { expect(buildCsv).toHaveBeenCalledTimes(1); }); + test("awaits a scheduled initial hydration before ready enables an immediate export", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const originalRead = repository.read.bind(repository); + const observer = createMutationObserverFactory(); + const successfulMetrics = { + rates: { + personalVideoAfterSearchRate: "0.2%", + singleVideoAfterSearchRate: "0.1%" + }, + success: true as const + }; + const deferredMetrics = createDeferred(); + let shouldReplaceInitialPage = true; + let activeMetricLoads = 0; + let maxActiveMetricLoads = 0; + repository.read = async () => { + const state = await originalRead(); + if (shouldReplaceInitialPage) { + shouldReplaceInitialPage = false; + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]); + observer.trigger(); + } + return state; + }; + const loadAuthorMetrics = vi.fn(async (authorId: string) => { + activeMetricLoads += 1; + maxActiveMetricLoads = Math.max(maxActiveMetricLoads, activeMetricLoads); + try { + if (authorId === "222") { + return await deferredMetrics.promise; + } + return successfulMetrics; + } finally { + activeMetricLoads -= 1; + } + }); + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + favoritesRepository: repository, + loadAuthorMetrics, + mutationObserverFactory: observer.factory, + onCsvReady: vi.fn(), + window + })); + let readySettled = false; + void controller.ready.then(() => { + readySettled = true; + }); + + await waitForCondition(() => + loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222") + ); + await Promise.resolve(); + + expect(readySettled).toBe(false); + + deferredMetrics.resolve(successfulMetrics); + await controller.ready; + + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 40, 50); + + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(["111", "222"]); + expect(maxActiveMetricLoads).toBe(1); + }); + test("keeps the friendly drawer error when a direct folder mutation has no message", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }