fix: preserve favorite identity and ready lifecycle
This commit is contained in:
@@ -84,6 +84,7 @@ export interface MarketRowDom {
|
|||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
|
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
|
||||||
|
coreUserId?: string;
|
||||||
exportFields?: Record<string, string>;
|
exportFields?: Record<string, string>;
|
||||||
hasDirectRatesSource?: boolean;
|
hasDirectRatesSource?: boolean;
|
||||||
location?: string;
|
location?: string;
|
||||||
@@ -660,6 +661,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
authorId,
|
authorId,
|
||||||
authorName,
|
authorName,
|
||||||
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
|
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
|
||||||
|
coreUserId: fallbackMarketRow?.coreUserId,
|
||||||
exportFields,
|
exportFields,
|
||||||
hasDirectRatesSource:
|
hasDirectRatesSource:
|
||||||
fallbackMarketRow?.hasDirectRatesSource ?? false,
|
fallbackMarketRow?.hasDirectRatesSource ?? false,
|
||||||
|
|||||||
+47
-44
@@ -232,6 +232,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
let scheduledSyncPromise: Promise<void> | null = null;
|
let scheduledSyncPromise: Promise<void> | null = null;
|
||||||
let resolveScheduledSync: (() => void) | null = null;
|
let resolveScheduledSync: (() => void) | null = null;
|
||||||
let rejectScheduledSync: ((reason?: unknown) => void) | null = null;
|
let rejectScheduledSync: ((reason?: unknown) => void) | null = null;
|
||||||
|
let toolbarRemountScheduled = false;
|
||||||
|
let lastNativeToolbarActionCount = -1;
|
||||||
const selectedAuthorIds = new Set<string>();
|
const selectedAuthorIds = new Set<string>();
|
||||||
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||||
let cachedFavoritesState: FavoritesStateV1 | undefined;
|
let cachedFavoritesState: FavoritesStateV1 | undefined;
|
||||||
@@ -269,6 +271,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
const toolbarNeedsRemount =
|
const toolbarNeedsRemount =
|
||||||
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
||||||
|
const nativeToolbarActionCount = readNativeToolbarActionCount(options.document);
|
||||||
|
const needsToolbarRemount =
|
||||||
|
toolbarNeedsRemount &&
|
||||||
|
(!toolbarRemountScheduled || nativeToolbarActionCount !== lastNativeToolbarActionCount);
|
||||||
|
if (!toolbarNeedsRemount) {
|
||||||
|
toolbarRemountScheduled = false;
|
||||||
|
}
|
||||||
const selectionControlsMissing =
|
const selectionControlsMissing =
|
||||||
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
||||||
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
||||||
@@ -279,7 +288,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
favoritesUnavailable && nextPageSignature !== lastKnownPageSignature;
|
favoritesUnavailable && nextPageSignature !== lastKnownPageSignature;
|
||||||
if (
|
if (
|
||||||
nextPageSignature === lastKnownPageSignature &&
|
nextPageSignature === lastKnownPageSignature &&
|
||||||
!toolbarNeedsRemount &&
|
!needsToolbarRemount &&
|
||||||
!selectionControlsMissing &&
|
!selectionControlsMissing &&
|
||||||
!favoriteControlsMissing &&
|
!favoriteControlsMissing &&
|
||||||
!needsUnavailableFavoritesRecovery
|
!needsUnavailableFavoritesRecovery
|
||||||
@@ -287,6 +296,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsToolbarRemount) {
|
||||||
|
toolbarRemountScheduled = true;
|
||||||
|
lastNativeToolbarActionCount = nativeToolbarActionCount;
|
||||||
|
}
|
||||||
if (favoriteControlsMissing || needsUnavailableFavoritesRecovery) {
|
if (favoriteControlsMissing || needsUnavailableFavoritesRecovery) {
|
||||||
needsFavoritesRefresh = true;
|
needsFavoritesRefresh = true;
|
||||||
}
|
}
|
||||||
@@ -535,9 +548,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const ready = (async () => {
|
const ready = (async () => {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await refreshFavorites();
|
await refreshFavorites();
|
||||||
const initialScheduledSync = scheduledSyncPromise;
|
await waitForSyncStabilization();
|
||||||
await waitForDomSettled();
|
|
||||||
await (initialScheduledSync ?? activeSyncPromise ?? scheduledSyncPromise);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -767,7 +778,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
folderIds.add(membership.folderId);
|
folderIds.add(membership.folderId);
|
||||||
folderIdsByAuthorId.set(membership.authorId, folderIds);
|
folderIdsByAuthorId.set(membership.authorId, folderIds);
|
||||||
});
|
});
|
||||||
const serializedCoreUserIds = readSerializedFavoriteCoreUserIds(options.document);
|
|
||||||
|
|
||||||
syncFavoriteRowPickers({
|
syncFavoriteRowPickers({
|
||||||
document: options.document,
|
document: options.document,
|
||||||
@@ -779,9 +789,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
actionCell: rowDom.actionCell,
|
actionCell: rowDom.actionCell,
|
||||||
authorId: rowDom.authorId,
|
authorId: rowDom.authorId,
|
||||||
authorName: rowDom.authorName,
|
authorName: rowDom.authorName,
|
||||||
coreUserId:
|
coreUserId: rowDom.coreUserId
|
||||||
(rowDom as MarketRowDom & { coreUserId?: string }).coreUserId ??
|
|
||||||
serializedCoreUserIds.get(rowDom.authorId)
|
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
favoriteActionButtons = Array.from(
|
favoriteActionButtons = Array.from(
|
||||||
@@ -1829,6 +1837,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return nextScheduledSyncPromise;
|
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 {
|
function runWithoutMutationSync(callback: () => void): void {
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
return;
|
return;
|
||||||
@@ -1892,6 +1915,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
async function runSingleSyncCycle(): Promise<void> {
|
async function runSingleSyncCycle(): Promise<void> {
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||||
|
if (isPluginToolbarMounted(toolbar.root, options.document)) {
|
||||||
|
toolbarRemountScheduled = false;
|
||||||
|
}
|
||||||
await hydrateCurrentPage();
|
await hydrateCurrentPage();
|
||||||
if (needsFavoritesRefresh) {
|
if (needsFavoritesRefresh) {
|
||||||
needsFavoritesRefresh = false;
|
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 {
|
function setScrollTop(element: HTMLElement, top: number): void {
|
||||||
element.scrollTop = top;
|
element.scrollTop = top;
|
||||||
element.dispatchEvent(new Event("scroll"));
|
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 {
|
function readFavoriteOperationError(error: unknown): string {
|
||||||
if (error instanceof Error && error.message.trim()) {
|
if (error instanceof Error && error.message.trim()) {
|
||||||
return error.message;
|
return error.message;
|
||||||
|
|||||||
@@ -4657,6 +4657,75 @@ describe("market-content-entry", () => {
|
|||||||
).toBe("saved");
|
).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 () => {
|
test("renders a creator once in all favorites and in its selected folder", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
@@ -5054,6 +5123,89 @@ describe("market-content-entry", () => {
|
|||||||
expect(maxActiveMetricLoads).toBe(1);
|
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 () => {
|
test("keeps the friendly drawer error when a direct folder mutation has no message", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ describe("market-dom-sync", () => {
|
|||||||
{
|
{
|
||||||
authorId: "111",
|
authorId: "111",
|
||||||
authorName: "达人 A",
|
authorName: "达人 A",
|
||||||
|
coreUserId: "core-111",
|
||||||
singleVideoAfterSearchRate: "0.02%"
|
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.authorId)).toEqual(["111", "222"]);
|
||||||
|
expect(table.rows.map((row) => row.coreUserId)).toEqual(["core-111", undefined]);
|
||||||
expect(table.rows[0].rates).toEqual({
|
expect(table.rows[0].rates).toEqual({
|
||||||
singleVideoAfterSearchRate: "0.02%"
|
singleVideoAfterSearchRate: "0.02%"
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user