fix: harden favorites controller lifecycle
This commit is contained in:
@@ -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<HTMLButtonElement>(ACTION_SELECTOR).forEach((button) => {
|
||||||
|
button.onclick = null;
|
||||||
|
button.remove();
|
||||||
|
});
|
||||||
|
removeFavoritePickerRoots(document);
|
||||||
|
}
|
||||||
|
|
||||||
type FavoritePickerContext = {
|
type FavoritePickerContext = {
|
||||||
button: HTMLButtonElement;
|
button: HTMLButtonElement;
|
||||||
creator: FavoriteCreatorInput;
|
creator: FavoriteCreatorInput;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import { promptForAuthorIds } from "./author-id-dialog";
|
|||||||
import { promptForBatchName } from "./batch-name-dialog";
|
import { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import { promptForFavoriteFolderName } from "./favorite-folder-dialog";
|
import { promptForFavoriteFolderName } from "./favorite-folder-dialog";
|
||||||
import { syncFavoriteRowPickers } from "./favorite-row-picker";
|
import {
|
||||||
|
disposeFavoriteRowPickers,
|
||||||
|
syncFavoriteRowPickers
|
||||||
|
} from "./favorite-row-picker";
|
||||||
import {
|
import {
|
||||||
createFavoritesDrawer,
|
createFavoritesDrawer,
|
||||||
type FavoritesDrawerController
|
type FavoritesDrawerController
|
||||||
@@ -232,6 +235,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
let cachedFavoritesState: FavoritesStateV1 | undefined;
|
let cachedFavoritesState: FavoritesStateV1 | undefined;
|
||||||
let favoritesRefreshVersion = 0;
|
let favoritesRefreshVersion = 0;
|
||||||
let favoriteActionButtons: HTMLButtonElement[] = [];
|
let favoriteActionButtons: HTMLButtonElement[] = [];
|
||||||
|
let needsFavoritesRefresh = false;
|
||||||
const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer(
|
const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer(
|
||||||
options.document,
|
options.document,
|
||||||
{
|
{
|
||||||
@@ -277,6 +281,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (favoriteControlsMissing) {
|
||||||
|
needsFavoritesRefresh = true;
|
||||||
|
}
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
});
|
});
|
||||||
const observationRoot = options.document.body ?? options.document.documentElement;
|
const observationRoot = options.document.body ?? options.document.documentElement;
|
||||||
@@ -522,7 +529,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const ready = (async () => {
|
const ready = (async () => {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await refreshFavorites();
|
await refreshFavorites();
|
||||||
await waitForDomSettled();
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -533,6 +539,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
options.window.clearTimeout(scheduledSyncTimeoutId);
|
options.window.clearTimeout(scheduledSyncTimeoutId);
|
||||||
scheduledSyncTimeoutId = null;
|
scheduledSyncTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
disposeFavoriteRowPickers(options.document);
|
||||||
favoritesDrawer.dispose();
|
favoritesDrawer.dispose();
|
||||||
},
|
},
|
||||||
ready
|
ready
|
||||||
@@ -725,6 +732,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cachedFavoritesState = undefined;
|
||||||
|
favoriteActionButtons = [];
|
||||||
|
disposeFavoriteRowPickers(options.document);
|
||||||
favoritesDrawer.setError("收藏夹暂不可用");
|
favoritesDrawer.setError("收藏夹暂不可用");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,8 +852,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
try {
|
try {
|
||||||
return await mutation();
|
return await mutation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
favoritesDrawer.setError(readFavoriteOperationError(error));
|
const message = readFavoriteOperationError(error);
|
||||||
throw error;
|
favoritesDrawer.setError(message);
|
||||||
|
throw new Error(message);
|
||||||
} finally {
|
} finally {
|
||||||
favoritesDrawer.setBusy(false);
|
favoritesDrawer.setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -1828,6 +1839,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
try {
|
try {
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||||
await hydrateCurrentPage();
|
await hydrateCurrentPage();
|
||||||
|
if (needsFavoritesRefresh) {
|
||||||
|
needsFavoritesRefresh = false;
|
||||||
|
await refreshFavorites();
|
||||||
|
}
|
||||||
applyCurrentView();
|
applyCurrentView();
|
||||||
lastKnownPageSignature = readMarketPageSignature(options.document);
|
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
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";
|
import type { FavoriteCreatorInput, FavoriteFolder } from "../src/content/market/favorites-store";
|
||||||
|
|
||||||
const firstFolder = createFolder("folder-1", "合作");
|
const firstFolder = createFolder("folder-1", "合作");
|
||||||
@@ -129,6 +132,7 @@ async function flushAsyncEvents(): Promise<void> {
|
|||||||
|
|
||||||
describe("favorite-row-picker", () => {
|
describe("favorite-row-picker", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
disposeFavoriteRowPickers(document);
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -355,6 +359,26 @@ describe("favorite-row-picker", () => {
|
|||||||
expect(readPopover()).toBeNull();
|
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", () => {
|
test("clamps a bottom-right picker within the viewport", () => {
|
||||||
const actionCell = createActionCell();
|
const actionCell = createActionCell();
|
||||||
const originalWidth = Object.getOwnPropertyDescriptor(
|
const originalWidth = Object.getOwnPropertyDescriptor(
|
||||||
|
|||||||
@@ -4573,6 +4573,43 @@ describe("market-content-entry", () => {
|
|||||||
expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单");
|
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 () => {
|
test("saves the serialized market row identity through its favorite picker", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
|
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
|
||||||
{ authorName: "达人 A", price21To60s: "¥11,000" }
|
{ 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-export="button"]')).not.toBeNull();
|
||||||
expect(document.querySelector('[data-plugin-batch-submit="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 {
|
function clearLocalStorage(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user