From ddf9a75e82756b6af20d8d0009ce12b83fdbce1f Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 16:40:06 +0800 Subject: [PATCH] fix: preserve favorite identity and ready lifecycle --- src/content/market/dom-sync.ts | 2 + src/content/market/index.ts | 91 ++++++++--------- tests/market-content-entry.test.ts | 152 +++++++++++++++++++++++++++++ tests/market-dom-sync.test.ts | 2 + 4 files changed, 203 insertions(+), 44 deletions(-) diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index d559277..03db366 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -84,6 +84,7 @@ export interface MarketRowDom { authorId: string; authorName: string; backendMetricsCells: Record; + coreUserId?: string; exportFields?: Record; hasDirectRatesSource?: boolean; location?: string; @@ -660,6 +661,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { authorId, authorName, backendMetricsCells: backendMetricsCells as Record, + coreUserId: fallbackMarketRow?.coreUserId, exportFields, hasDirectRatesSource: fallbackMarketRow?.hasDirectRatesSource ?? false, diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 96587ba..4795722 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -232,6 +232,8 @@ export function createMarketController(options: CreateMarketControllerOptions) { let scheduledSyncPromise: Promise | null = null; let resolveScheduledSync: (() => void) | null = null; let rejectScheduledSync: ((reason?: unknown) => void) | null = null; + let toolbarRemountScheduled = false; + let lastNativeToolbarActionCount = -1; const selectedAuthorIds = new Set(); let toolbar: ReturnType | undefined; let cachedFavoritesState: FavoritesStateV1 | undefined; @@ -269,6 +271,13 @@ export function createMarketController(options: CreateMarketControllerOptions) { const toolbarNeedsRemount = !toolbar || !isPluginToolbarMounted(toolbar.root, options.document); + const nativeToolbarActionCount = readNativeToolbarActionCount(options.document); + const needsToolbarRemount = + toolbarNeedsRemount && + (!toolbarRemountScheduled || nativeToolbarActionCount !== lastNativeToolbarActionCount); + if (!toolbarNeedsRemount) { + toolbarRemountScheduled = false; + } const selectionControlsMissing = !options.document.querySelector('[data-market-selection-checkbox="row"]') || !options.document.querySelector('[data-market-selection-checkbox="header"]'); @@ -279,7 +288,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { favoritesUnavailable && nextPageSignature !== lastKnownPageSignature; if ( nextPageSignature === lastKnownPageSignature && - !toolbarNeedsRemount && + !needsToolbarRemount && !selectionControlsMissing && !favoriteControlsMissing && !needsUnavailableFavoritesRecovery @@ -287,6 +296,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } + if (needsToolbarRemount) { + toolbarRemountScheduled = true; + lastNativeToolbarActionCount = nativeToolbarActionCount; + } if (favoriteControlsMissing || needsUnavailableFavoritesRecovery) { needsFavoritesRefresh = true; } @@ -535,9 +548,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { const ready = (async () => { await runSyncCycle(); await refreshFavorites(); - const initialScheduledSync = scheduledSyncPromise; - await waitForDomSettled(); - await (initialScheduledSync ?? activeSyncPromise ?? scheduledSyncPromise); + await waitForSyncStabilization(); })(); return { @@ -767,7 +778,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { folderIds.add(membership.folderId); folderIdsByAuthorId.set(membership.authorId, folderIds); }); - const serializedCoreUserIds = readSerializedFavoriteCoreUserIds(options.document); syncFavoriteRowPickers({ document: options.document, @@ -779,9 +789,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { actionCell: rowDom.actionCell, authorId: rowDom.authorId, authorName: rowDom.authorName, - coreUserId: - (rowDom as MarketRowDom & { coreUserId?: string }).coreUserId ?? - serializedCoreUserIds.get(rowDom.authorId) + coreUserId: rowDom.coreUserId })) }); favoriteActionButtons = Array.from( @@ -1829,6 +1837,21 @@ export function createMarketController(options: CreateMarketControllerOptions) { return nextScheduledSyncPromise; } + async function waitForSyncStabilization(): Promise { + while (!isDisposed) { + const pendingSync = activeSyncPromise ?? scheduledSyncPromise; + if (pendingSync) { + await pendingSync; + continue; + } + + await waitForDomSettled(); + if (!activeSyncPromise && !scheduledSyncPromise) { + return; + } + } + } + function runWithoutMutationSync(callback: () => void): void { if (isDisposed) { return; @@ -1892,6 +1915,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { async function runSingleSyncCycle(): Promise { toolbar = ensurePluginToolbar(options.document, toolbarHandlers); + if (isPluginToolbarMounted(toolbar.root, options.document)) { + toolbarRemountScheduled = false; + } await hydrateCurrentPage(); if (needsFavoritesRefresh) { needsFavoritesRefresh = false; @@ -1903,6 +1929,19 @@ export function createMarketController(options: CreateMarketControllerOptions) { } +function readNativeToolbarActionCount(document: Document): number { + return Array.from(document.querySelectorAll("button, a, [role='button']")).filter( + (element) => { + if (element.closest("[data-plugin-toolbar='root']")) { + return false; + } + + const text = element.textContent?.replace(/\s+/g, "").trim(); + return text === "自定义指标" || text === "导出"; + } + ).length; +} + function setScrollTop(element: HTMLElement, top: number): void { element.scrollTop = top; element.dispatchEvent(new Event("scroll")); @@ -1944,42 +1983,6 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { }; } -function readSerializedFavoriteCoreUserIds(document: Document): Map { - const coreUserIds = new Map(); - const serializedRows = document.documentElement.getAttribute("data-sces-market-rows"); - if (!serializedRows) { - return coreUserIds; - } - - try { - const rows = JSON.parse(serializedRows) as unknown; - if (!Array.isArray(rows)) { - return coreUserIds; - } - - rows.forEach((row) => { - if (!row || typeof row !== "object") { - return; - } - - const authorId = (row as { authorId?: unknown }).authorId; - const coreUserId = (row as { coreUserId?: unknown }).coreUserId; - if ( - typeof authorId === "string" && - authorId.trim() && - typeof coreUserId === "string" && - coreUserId.trim() - ) { - coreUserIds.set(authorId.trim(), coreUserId.trim()); - } - }); - } catch { - return coreUserIds; - } - - return coreUserIds; -} - function readFavoriteOperationError(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message; diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 84cdf32..1cd4eff 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -4657,6 +4657,75 @@ describe("market-content-entry", () => { ).toBe("saved"); }); + test("preserves a late serialized core user id through favorite batch import", async () => { + document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ + { authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const observer = createMutationObserverFactory(); + const promptBatchName = vi.fn(() => "收藏夹批次"); + const promptFavoriteFolderName = vi.fn(() => "母婴优质达人"); + const submitBatch = vi.fn(async () => ({ ok: true })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + getAuthState: async () => authenticatedTestState(), + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + mutationObserverFactory: observer.factory, + promptBatchName, + promptFavoriteFolderName, + submitBatch, + window + })); + + await controller.ready; + document.documentElement.setAttribute( + "data-sces-market-rows", + JSON.stringify([ + { authorId: "111", authorName: "达人 A", coreUserId: "core-111" } + ]) + ); + document.documentElement.setAttribute("data-test-page-index", "2"); + observer.trigger(); + await waitForCondition( + () => { + const favoriteButton = document.querySelector( + '[data-sces-favorite-row-action="button"]' + ); + return favoriteButton instanceof HTMLButtonElement && !favoriteButton.disabled; + } + ); + + click('[data-sces-favorite-row-action="button"]'); + click('[data-sces-favorite-row-picker="create-folder"]'); + await waitForCondition( + () => + document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset + .scesFavoriteState === "saved" + ); + + click('[data-sces-favorites-tab="button"]'); + ( + document.querySelector( + '[data-sces-favorites-select-author-id="111"]' + ) as HTMLInputElement + ).click(); + click('[data-sces-favorites-drawer="import-selected"]'); + await waitForMockCall(submitBatch); + + expect(repository.getStoredState()).toMatchObject({ + creators: [{ authorId: "111", coreUserId: "core-111" }] + }); + expect(submitBatch).toHaveBeenCalledWith( + expect.objectContaining({ + authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }], + batchName: "收藏夹批次" + }) + ); + }); + test("renders a creator once in all favorites and in its selected folder", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -5054,6 +5123,89 @@ describe("market-content-entry", () => { expect(maxActiveMetricLoads).toBe(1); }); + test("awaits a follow-up sync triggered during initial scheduled hydration", 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 deferredSecondPageMetrics = createDeferred(); + const deferredThirdPageMetrics = createDeferred(); + let shouldReplaceInitialPage = true; + 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((authorId: string) => { + if (authorId === "222") { + return deferredSecondPageMetrics.promise; + } + if (authorId === "333") { + return deferredThirdPageMetrics.promise; + } + return Promise.resolve(successfulMetrics); + }); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics, + mutationObserverFactory: observer.factory, + window + })); + let readySettled = false; + void controller.ready.then(() => { + readySettled = true; + }); + + await waitForCondition(() => + loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222") + ); + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } + ]); + observer.trigger(); + deferredSecondPageMetrics.resolve(successfulMetrics); + await waitForCondition(() => + loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "333") + ); + await Promise.resolve(); + + expect(readySettled).toBe(false); + + deferredThirdPageMetrics.resolve({ + rates: { + personalVideoAfterSearchRate: "0.33%", + singleVideoAfterSearchRate: "0.33%" + }, + success: true + }); + await controller.ready; + + expect( + document.querySelector('[data-market-selection-checkbox="row"]')?.getAttribute( + "data-market-selection-author-id" + ) + ).toBe("333"); + expect(readDivPluginRowTexts(0)[0]).toBe("0.33%"); + }); + 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" } diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index f769522..c2211c5 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -610,6 +610,7 @@ describe("market-dom-sync", () => { { authorId: "111", authorName: "达人 A", + coreUserId: "core-111", singleVideoAfterSearchRate: "0.02%" }, { @@ -625,6 +626,7 @@ describe("market-dom-sync", () => { } expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); + expect(table.rows.map((row) => row.coreUserId)).toEqual(["core-111", undefined]); expect(table.rows[0].rates).toEqual({ singleVideoAfterSearchRate: "0.02%" });