fix: preserve favorite identity and ready lifecycle

This commit is contained in:
wxs
2026-07-17 16:41:11 +08:00
parent 8ca9f95583
commit ddf9a75e82
4 changed files with 203 additions and 44 deletions
+2
View File
@@ -84,6 +84,7 @@ export interface MarketRowDom {
authorId: string;
authorName: string;
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
coreUserId?: string;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
@@ -660,6 +661,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
authorId,
authorName,
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
coreUserId: fallbackMarketRow?.coreUserId,
exportFields,
hasDirectRatesSource:
fallbackMarketRow?.hasDirectRatesSource ?? false,
+47 -44
View File
@@ -232,6 +232,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
let scheduledSyncPromise: Promise<void> | null = null;
let resolveScheduledSync: (() => void) | null = null;
let rejectScheduledSync: ((reason?: unknown) => void) | null = null;
let toolbarRemountScheduled = false;
let lastNativeToolbarActionCount = -1;
const selectedAuthorIds = new Set<string>();
let toolbar: ReturnType<typeof ensurePluginToolbar> | 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<void> {
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<void> {
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<string, string> {
const coreUserIds = new Map<string, string>();
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;
+152
View File
@@ -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<typeof successfulMetrics>();
const deferredThirdPageMetrics = createDeferred<typeof successfulMetrics>();
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" }
+2
View File
@@ -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%"
});