diff --git a/src/content/market/favorite-row-picker.ts b/src/content/market/favorite-row-picker.ts index e6878a2..caed31b 100644 --- a/src/content/market/favorite-row-picker.ts +++ b/src/content/market/favorite-row-picker.ts @@ -87,6 +87,26 @@ export function syncFavoriteRowPickers(options: { } } +export function disposeFavoriteRowPickers(document: Document): void { + const state = pickerDocumentStates.get(document); + if (state) { + closeFavoritePicker(document, state); + state.contextByButton.forEach((_context, button) => { + button.onclick = null; + button.remove(); + }); + state.contextByButton.clear(); + state.pendingOperations.clear(); + pickerDocumentStates.delete(document); + } + + document.querySelectorAll(ACTION_SELECTOR).forEach((button) => { + button.onclick = null; + button.remove(); + }); + removeFavoritePickerRoots(document); +} + type FavoritePickerContext = { button: HTMLButtonElement; creator: FavoriteCreatorInput; diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 0952fb5..e9eb466 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -17,7 +17,10 @@ import { promptForAuthorIds } from "./author-id-dialog"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { promptForFavoriteFolderName } from "./favorite-folder-dialog"; -import { syncFavoriteRowPickers } from "./favorite-row-picker"; +import { + disposeFavoriteRowPickers, + syncFavoriteRowPickers +} from "./favorite-row-picker"; import { createFavoritesDrawer, type FavoritesDrawerController @@ -232,6 +235,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { let cachedFavoritesState: FavoritesStateV1 | undefined; let favoritesRefreshVersion = 0; let favoriteActionButtons: HTMLButtonElement[] = []; + let needsFavoritesRefresh = false; const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer( options.document, { @@ -277,6 +281,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } + if (favoriteControlsMissing) { + needsFavoritesRefresh = true; + } scheduleSync(); }); const observationRoot = options.document.body ?? options.document.documentElement; @@ -522,7 +529,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { const ready = (async () => { await runSyncCycle(); await refreshFavorites(); - await waitForDomSettled(); })(); return { @@ -533,6 +539,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { options.window.clearTimeout(scheduledSyncTimeoutId); scheduledSyncTimeoutId = null; } + disposeFavoriteRowPickers(options.document); favoritesDrawer.dispose(); }, ready @@ -725,6 +732,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } + cachedFavoritesState = undefined; + favoriteActionButtons = []; + disposeFavoriteRowPickers(options.document); favoritesDrawer.setError("收藏夹暂不可用"); } } @@ -842,8 +852,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { try { return await mutation(); } catch (error) { - favoritesDrawer.setError(readFavoriteOperationError(error)); - throw error; + const message = readFavoriteOperationError(error); + favoritesDrawer.setError(message); + throw new Error(message); } finally { favoritesDrawer.setBusy(false); } @@ -1828,6 +1839,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { try { toolbar = ensurePluginToolbar(options.document, toolbarHandlers); await hydrateCurrentPage(); + if (needsFavoritesRefresh) { + needsFavoritesRefresh = false; + await refreshFavorites(); + } applyCurrentView(); lastKnownPageSignature = readMarketPageSignature(options.document); } finally { diff --git a/tests/favorite-row-picker.test.ts b/tests/favorite-row-picker.test.ts index c4cd12a..198288c 100644 --- a/tests/favorite-row-picker.test.ts +++ b/tests/favorite-row-picker.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { syncFavoriteRowPickers } from "../src/content/market/favorite-row-picker"; +import { + disposeFavoriteRowPickers, + syncFavoriteRowPickers +} from "../src/content/market/favorite-row-picker"; import type { FavoriteCreatorInput, FavoriteFolder } from "../src/content/market/favorites-store"; const firstFolder = createFolder("folder-1", "合作"); @@ -129,6 +132,7 @@ async function flushAsyncEvents(): Promise { describe("favorite-row-picker", () => { beforeEach(() => { + disposeFavoriteRowPickers(document); document.body.innerHTML = ""; }); @@ -355,6 +359,26 @@ describe("favorite-row-picker", () => { expect(readPopover()).toBeNull(); }); + test("disposes an open picker and clears its detached action handler", () => { + const actionCell = createActionCell(); + const onSetCreatorFolderIds = vi.fn(async () => {}); + + sync({ actionCell, onSetCreatorFolderIds }); + const button = readFavoriteButton(actionCell); + button.click(); + expect(readPopover()).not.toBeNull(); + + disposeFavoriteRowPickers(document); + disposeFavoriteRowPickers(document); + + expect(readPopover()).toBeNull(); + expect(button.onclick).toBeNull(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); + button.click(); + expect(onSetCreatorFolderIds).not.toHaveBeenCalled(); + }); + test("clamps a bottom-right picker within the viewport", () => { const actionCell = createActionCell(); const originalWidth = Object.getOwnPropertyDescriptor( diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index ec28c86..af4b27c 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -4573,6 +4573,43 @@ describe("market-content-entry", () => { expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单"); }); + test("disposes an open favorite picker without retaining row callbacks", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const createFolder = vi.fn(repository.createFolder.bind(repository)); + const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository)); + repository.createFolder = createFolder; + repository.setCreatorFolderIds = setCreatorFolderIds; + + const { createMarketController } = await import("../src/content/market/index"); + const controller = createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + }); + + await controller.ready; + const favoriteButton = document.querySelector( + '[data-sces-favorite-row-action="button"]' + ) as HTMLButtonElement; + favoriteButton.click(); + expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).not.toBeNull(); + + controller.dispose(); + + expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).toBeNull(); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + favoriteButton.click(); + await flush(); + + expect(createFolder).not.toHaveBeenCalled(); + expect(setCreatorFolderIds).not.toHaveBeenCalled(); + }); + test("saves the serialized market row identity through its favorite picker", async () => { document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ { authorName: "达人 A", price21To60s: "¥11,000" } @@ -4834,6 +4871,85 @@ describe("market-content-entry", () => { expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); }); + + test("clears stale favorite pickers when a later repository refresh fails", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const folder = await repository.createFolder("母婴优质达人"); + await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]); + const originalRead = repository.read.bind(repository); + let shouldRejectRead = false; + repository.read = async () => { + if (shouldRejectRead) { + throw new Error("storage unavailable"); + } + return originalRead(); + }; + const observer = createMutationObserverFactory(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + mutationObserverFactory: observer.factory, + window + })); + + await controller.ready; + expect( + document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState + ).toBe("saved"); + + shouldRejectRead = true; + document.querySelector('[data-testid="action-cell-111"]')!.replaceChildren( + document.createTextNode("下单") + ); + observer.trigger(); + await waitForCondition( + () => + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === + "收藏夹暂不可用" + ); + + expect(document.querySelector('[data-sces-favorite-row-action="button"]')).toBeNull(); + expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + }); + + 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" } + ]); + const repository = createTestFavoritesRepository(); + repository.createFolder = async () => { + throw new Error(); + }; + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + promptFavoriteFolderName: () => "不应出现的收藏夹", + window + })); + + await controller.ready; + click('[data-sces-favorites-tab="button"]'); + click('[data-sces-favorites-drawer="create-folder"]'); + await waitForCondition( + () => + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === + "收藏夹操作失败,请稍后重试" + ); + + expect(document.querySelectorAll('[data-sces-favorites-folder-id]')).toHaveLength(0); + expect(document.querySelector('[data-sces-favorites-drawer="creators"]')?.textContent).toContain( + "暂无收藏达人" + ); + }); }); function clearLocalStorage(): void {