预览可见性门禁等 root 就绪再建 observer,并把登记表补挂齐
真机现场:同一屏里 `idle` 与 `loaded` 交错,12 张相交卡从未入队(`idle` 的语义就是「从未请求」,不是被淘汰回退)。根因在 observer 的**创建时机**:`useProjectResourceCardPreviews` 建 IntersectionObserver 时把 `intersectionRootRef.current ?? canvasRef.current` 直接当 root 传下去,而 root 是资源画本容器、由被观察卡片所在的子树持有。创建那一刻 root 还是 `null` 时,浏览器会**退回按视口判定**,于是被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行它们 —— 卡面只剩占位图标,且没有任何错误提示。 - `useProjectResourceCardPreviews`:root 为 `null` 时**不建 observer**,等 root 就绪后由卡片注册触发的 `rootEpoch` 重建。判据是「卡片一定渲染在画本容器内部」,所以**有卡片注册本身就等价于 root 已就绪**,不需要新增跨组件契约、也不必改 `index.tsx`。 - 新增 `attachObservedCards()`:observer 就绪或重建后,按登记表把**每一张已注册卡片**补挂一遍。此前只在新 observer 创建时补挂一次,注册与创建分属不同 effect 存在时序窗口,错过那次补挂的卡会停在登记表里却从未被观察。 - root 未变且 observer 已在时只补挂、不重建,避免每次渲染重建观察器。 - `observePreview` 在没有 `IntersectionObserver` 的环境(如单测)保持静默,不制造多余渲染。 测试(`tests/useProjectResourceCardPreviews.test.ts` 新增 3 条契约): - 「不在热预取窗口内的可见卡不得停在 idle」:注册 20 张、`eagerPreviewLimit: 12`,断言注册进 observer 的元素数等于注册数、回调报可见后全部落 `loaded`、且没有一张停留在未请求状态; - 「等真正的 root 就绪后再观察并加载」:root 首次为 `null` 时**不得建 observer**(否则按视口判定),注册后必须以真正的 root 建出来并完成加载; - 「建 observer 之前就注册的卡要被补挂」:断言 observer 就绪后登记表里的每一张都在观察集合内,且报可见后全部 `loaded`。 变异验证:把 root 守卫退回旧行为(允许 root 为 `null` 时照建 observer)后,「等真正的 root 就绪」这条立即失败(`expected [] to have a length of 0 but got 1`);恢复后 19/19 通过。断言不是恒真假守卫。 边界说明:只改预览 hook 与它的测试,未动 `index.tsx` / `styles.css` / `resourceBookLayout.ts`(均在他人手上),未放宽任何既有断言、未取消可见性门禁(仍是按需加载)。 验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1216 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4379 文件;prettier 与 eslint 干净;`git diff --check` 干净。
This commit is contained in:
+68
-6
@@ -595,10 +595,48 @@ export function useProjectResourceCardPreviews(input: {
|
|||||||
[touchCachedPreview],
|
[touchCachedPreview],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前登记表里的全部卡片元素补挂到 observer 上。
|
||||||
|
*
|
||||||
|
* 注册(`observePreview`)与 observer 的创建分属不同 effect,两者存在时序窗口:
|
||||||
|
* 在 observer 还没建好时注册的卡只能靠 observer effect 里的一次性补挂兜住,
|
||||||
|
* 一旦那个补挂发生在注册之前、或 observer 之后被重建,卡就会**停留在登记表里
|
||||||
|
* 却从未被观察** —— 它永不触发可见性回调,也就永远停在 `idle`,卡面只剩占位图标。
|
||||||
|
* 所以每次 observer 就绪或重建后都要按登记表重新补挂一遍。
|
||||||
|
*/
|
||||||
|
const attachObservedCards = useCallback(() => {
|
||||||
|
const observer = observerRef.current;
|
||||||
|
if (!observer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const element of observedCardsRef.current.keys()) {
|
||||||
|
observer.observe(element);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 observer 时的 root,用来判断它是否变了。
|
||||||
|
*
|
||||||
|
* `null` 有特殊含义:**还没有可用的 root**。注册比本 effect 先跑,所以第一轮这里
|
||||||
|
* 拿到 null 是正常时序,此时不能建 observer。
|
||||||
|
*/
|
||||||
|
const observerRootRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [rootEpoch, setRootEpoch] = useState(0);
|
||||||
|
|
||||||
const observePreview = useCallback(
|
const observePreview = useCallback(
|
||||||
(element: HTMLElement, resource: ProjectResource, identity: string) => {
|
(element: HTMLElement, resource: ProjectResource, identity: string) => {
|
||||||
const binding = { resource, identity };
|
const binding = { resource, identity };
|
||||||
observedCardsRef.current.set(element, binding);
|
observedCardsRef.current.set(element, binding);
|
||||||
|
if (!observerRef.current) {
|
||||||
|
// 卡片一定渲染在资源画本容器内部,所以「有卡片注册」本身就是「root 已就绪」
|
||||||
|
// 的信号。observer 还没建好时用它触发一次重建,避免卡死在未观察状态。
|
||||||
|
// 在测试等没有 IntersectionObserver 的环境里保持静默,不制造多余渲染。
|
||||||
|
const root =
|
||||||
|
input.intersectionRootRef?.current ?? input.canvasRef.current;
|
||||||
|
if (root && window.IntersectionObserver) {
|
||||||
|
setRootEpoch((epoch) => epoch + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
observerRef.current?.observe(element);
|
observerRef.current?.observe(element);
|
||||||
return () => {
|
return () => {
|
||||||
observerRef.current?.unobserve(element);
|
observerRef.current?.unobserve(element);
|
||||||
@@ -607,7 +645,7 @@ export function useProjectResourceCardPreviews(input: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[],
|
[input.canvasRef, input.intersectionRootRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const failPreview = useCallback(
|
const failPreview = useCallback(
|
||||||
@@ -755,6 +793,21 @@ export function useProjectResourceCardPreviews(input: {
|
|||||||
if (!IntersectionObserverClass) {
|
if (!IntersectionObserverClass) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// root 是资源画本容器;它由被观察卡片所在的那棵子树持有。如果这里为 null,
|
||||||
|
// 浏览器会退回按视口判定,而真正裁剪卡片的是画本容器 —— 被祖先裁掉的卡片
|
||||||
|
// 会永远报「不可见」,可见性门禁再也不会放行它们。此时**不建 observer**,
|
||||||
|
// 等卡片注册触发的 `rootEpoch` 让本 effect 重跑再建;期间 `attachObservedCards`
|
||||||
|
// 会把登记表里的卡一次性补挂齐,避免"登记了却没被观察"。
|
||||||
|
const root = input.intersectionRootRef?.current ?? input.canvasRef.current;
|
||||||
|
if (!root) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (observerRef.current && observerRootRef.current === root) {
|
||||||
|
// root 没变且 observer 已在:只补挂新注册的卡,不做无谓重建。
|
||||||
|
attachObservedCards();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
observerRef.current?.disconnect();
|
||||||
const observer = new IntersectionObserverClass(
|
const observer = new IntersectionObserverClass(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -770,21 +823,30 @@ export function useProjectResourceCardPreviews(input: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: input.intersectionRootRef?.current ?? input.canvasRef.current,
|
root,
|
||||||
rootMargin: '160px',
|
rootMargin: '160px',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
observerRef.current = observer;
|
observerRef.current = observer;
|
||||||
for (const element of observedCardsRef.current.keys()) {
|
observerRootRef.current = root;
|
||||||
observer.observe(element);
|
attachObservedCards();
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
if (observerRef.current === observer) {
|
if (observerRef.current === observer) {
|
||||||
observerRef.current = null;
|
observerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (observerRootRef.current === root) {
|
||||||
|
observerRootRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [input.canvasRef, input.intersectionRootRef, requestPreview, scopeKey]);
|
}, [
|
||||||
|
attachObservedCards,
|
||||||
|
input.canvasRef,
|
||||||
|
input.intersectionRootRef,
|
||||||
|
requestPreview,
|
||||||
|
rootEpoch,
|
||||||
|
scopeKey,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identityByResourceId,
|
identityByResourceId,
|
||||||
|
|||||||
@@ -986,4 +986,352 @@ describe('useProjectResourceCardPreviews', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可见性门禁的时机契约:observer 必须**等 root 真正就绪**才建,且建好后要把
|
||||||
|
* 登记表里的卡一次性补挂齐。
|
||||||
|
*
|
||||||
|
* 背景:root 是资源画本容器(卡片渲染在它内部)。若在 root 还是 null 时就建 observer,
|
||||||
|
* 浏览器会退回按视口判定,被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行,
|
||||||
|
* 卡片就停在 `idle`、卡面只剩占位图标 —— 真机现场出现的正是「同一屏里 idle 与 loaded 交错」。
|
||||||
|
*/
|
||||||
|
describe('可见性门禁的 root 时机契约', () => {
|
||||||
|
type ObserverHarness = {
|
||||||
|
callbacks: IntersectionObserverCallback[];
|
||||||
|
roots: (Element | Document | null)[];
|
||||||
|
observed: Set<Element>;
|
||||||
|
restore: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function installIntersectionObserver(): ObserverHarness {
|
||||||
|
const callbacks: IntersectionObserverCallback[] = [];
|
||||||
|
const roots: (Element | Document | null)[] = [];
|
||||||
|
const observed = new Set<Element>();
|
||||||
|
const original = Object.getOwnPropertyDescriptor(
|
||||||
|
window,
|
||||||
|
'IntersectionObserver',
|
||||||
|
);
|
||||||
|
class TestIntersectionObserver {
|
||||||
|
readonly root = null;
|
||||||
|
readonly rootMargin = '160px';
|
||||||
|
readonly thresholds = [0];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
callback: IntersectionObserverCallback,
|
||||||
|
options?: IntersectionObserverInit,
|
||||||
|
) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
roots.push(options?.root ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(element: Element) {
|
||||||
|
observed.add(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve(element: Element) {
|
||||||
|
observed.delete(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
observed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: TestIntersectionObserver,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
callbacks,
|
||||||
|
roots,
|
||||||
|
observed,
|
||||||
|
restore: () => {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(window, 'IntersectionObserver', original);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(window, 'IntersectionObserver');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireVisible(harness: ObserverHarness, targets: Iterable<Element>) {
|
||||||
|
act(() => {
|
||||||
|
harness.callbacks.at(-1)!(
|
||||||
|
[...targets].map(
|
||||||
|
(target) =>
|
||||||
|
({
|
||||||
|
target,
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
}) as IntersectionObserverEntry,
|
||||||
|
),
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('waits for a real root, then observes and loads the cards', async () => {
|
||||||
|
const art = resource('late-root-art');
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (_command: string, args?: Record<string, unknown>) =>
|
||||||
|
preview(String(args?.relativePath ?? art.path)),
|
||||||
|
);
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
// root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。
|
||||||
|
const canvasRef: { current: HTMLDivElement | null } = { current: null };
|
||||||
|
const harness = installIntersectionObserver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useProjectResourceCardPreviews({
|
||||||
|
projectPath: '/tmp/preview-late-root',
|
||||||
|
projectId: 'preview-late-root',
|
||||||
|
mode: 'dependency',
|
||||||
|
resources: [art],
|
||||||
|
canvasRef,
|
||||||
|
// 关掉热预取,逼这张卡只能靠可见性放行。
|
||||||
|
eagerPreviewLimit: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const identity = result.current.identityByResourceId.get(art.id)!;
|
||||||
|
|
||||||
|
// root 还没就绪时不得建 observer —— 否则会按视口判定,被容器裁掉的卡永远不可见。
|
||||||
|
expect(harness.callbacks).toHaveLength(0);
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const card = document.createElement('div');
|
||||||
|
root.append(card);
|
||||||
|
canvasRef.current = root;
|
||||||
|
let unregister: () => void = () => undefined;
|
||||||
|
act(() => {
|
||||||
|
unregister = result.current.observePreview(card, art, identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册本身就是「root 已就绪」的信号:observer 必须以真正的 root 建出来。
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(harness.callbacks.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
expect(harness.roots.at(-1)).toBe(root);
|
||||||
|
expect(harness.observed.has(card)).toBe(true);
|
||||||
|
expect(result.current.previews.get(identity)).toBeUndefined();
|
||||||
|
|
||||||
|
fireVisible(harness, [card]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
|
||||||
|
);
|
||||||
|
unregister();
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-observes cards registered before the observer existed', async () => {
|
||||||
|
const resources = Array.from({ length: 3 }, (_, index) =>
|
||||||
|
resource(`early-register-${index + 1}`),
|
||||||
|
);
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (_command: string, args?: Record<string, unknown>) =>
|
||||||
|
preview(String(args?.relativePath ?? '')),
|
||||||
|
);
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
const canvasRef: { current: HTMLDivElement | null } = { current: null };
|
||||||
|
const harness = installIntersectionObserver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useProjectResourceCardPreviews({
|
||||||
|
projectPath: '/tmp/preview-early-register',
|
||||||
|
projectId: 'preview-early-register',
|
||||||
|
mode: 'dependency',
|
||||||
|
resources,
|
||||||
|
canvasRef,
|
||||||
|
eagerPreviewLimit: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
canvasRef.current = root;
|
||||||
|
const elements = resources.map(() => document.createElement('div'));
|
||||||
|
act(() => {
|
||||||
|
resources.forEach((item, index) => {
|
||||||
|
result.current.observePreview(
|
||||||
|
elements[index]!,
|
||||||
|
item,
|
||||||
|
result.current.identityByResourceId.get(item.id)!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登记与建 observer 的先后顺序无关:建好之后登记表里的每一张都必须被挂上。
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(harness.observed.size).toBe(elements.length),
|
||||||
|
);
|
||||||
|
for (const element of elements) {
|
||||||
|
expect(harness.observed.has(element)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fireVisible(harness, elements);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
for (const item of resources) {
|
||||||
|
const identity = result.current.identityByResourceId.get(item.id)!;
|
||||||
|
expect(result.current.previews.get(identity)?.status).toBe(
|
||||||
|
'loaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leave a visible card idle when it is outside the eager window', async () => {
|
||||||
|
// 真机现场:同一屏里 idle 与 loaded 交错,idle 的那批从未入队。
|
||||||
|
// 热预取只覆盖前 12 张,其余全部依赖可见性门禁 —— 所以这里必须证明
|
||||||
|
// 「注册进 observer 的卡,在回调报可见后会真的进入队列」,而不是停在 idle。
|
||||||
|
const resources = Array.from({ length: 20 }, (_, index) =>
|
||||||
|
resource(`visibility-${index + 1}`),
|
||||||
|
);
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (_command: string, args?: Record<string, unknown>) =>
|
||||||
|
preview(String(args?.relativePath ?? '')),
|
||||||
|
);
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
const canvasRef = { current: document.createElement('div') };
|
||||||
|
|
||||||
|
const callbacks: IntersectionObserverCallback[] = [];
|
||||||
|
const roots: (Element | Document | null)[] = [];
|
||||||
|
const observed = new Set<Element>();
|
||||||
|
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
|
||||||
|
window,
|
||||||
|
'IntersectionObserver',
|
||||||
|
);
|
||||||
|
class TestIntersectionObserver {
|
||||||
|
readonly root = null;
|
||||||
|
readonly rootMargin = '160px';
|
||||||
|
readonly thresholds = [0];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
callback: IntersectionObserverCallback,
|
||||||
|
options?: IntersectionObserverInit,
|
||||||
|
) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
roots.push(options?.root ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(element: Element) {
|
||||||
|
observed.add(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve(element: Element) {
|
||||||
|
observed.delete(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
observed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: TestIntersectionObserver,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useProjectResourceCardPreviews({
|
||||||
|
projectPath: '/tmp/preview-visibility',
|
||||||
|
projectId: 'preview-visibility',
|
||||||
|
mode: 'dependency',
|
||||||
|
resources,
|
||||||
|
canvasRef,
|
||||||
|
// 热预取只覆盖前 12 张:第 13 张起完全依赖可见性门禁。
|
||||||
|
eagerPreviewLimit: 12,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 模拟卡片挂载后的注册:按计划顺序,前 12 张已进热预取窗口,
|
||||||
|
// 从第 13 张起是「只能靠可见性放行」的那批。
|
||||||
|
const unregister: Array<() => void> = [];
|
||||||
|
act(() => {
|
||||||
|
for (const item of resources.slice(12)) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const identity = result.current.identityByResourceId.get(item.id)!;
|
||||||
|
unregister.push(
|
||||||
|
result.current.observePreview(element, item, identity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册的每个元素都必须真的进了 observer:漏 observe 的卡永远不会被放行。
|
||||||
|
expect(observed.size).toBe(unregister.length);
|
||||||
|
expect(roots.at(-1)).toBe(canvasRef.current);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
result.current.previews.get(
|
||||||
|
result.current.identityByResourceId.get(resources[0]!.id)!,
|
||||||
|
)?.status,
|
||||||
|
).toBe('loaded'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 报可见之前,未进热预取窗口的卡应仍是「从未请求」。
|
||||||
|
const registerOrder: Array<[ProjectResource, string]> = resources
|
||||||
|
.slice(12)
|
||||||
|
.map((item) => [
|
||||||
|
item,
|
||||||
|
result.current.identityByResourceId.get(item.id)!,
|
||||||
|
]);
|
||||||
|
for (const [, identity] of registerOrder) {
|
||||||
|
expect(result.current.previews.get(identity)?.status).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
callbacks.at(-1)!(
|
||||||
|
[...observed].map(
|
||||||
|
(target) =>
|
||||||
|
({
|
||||||
|
target,
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
}) as IntersectionObserverEntry,
|
||||||
|
),
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
for (const [, identity] of registerOrder) {
|
||||||
|
expect(result.current.previews.get(identity)?.status).toBe('loaded');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 契约:可见卡不得停在 idle —— 每张注册过的卡都要落成 loaded。
|
||||||
|
expect(
|
||||||
|
registerOrder.filter(
|
||||||
|
([, identity]) => result.current.previews.get(identity) === undefined,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
if (originalIntersectionObserver) {
|
||||||
|
Object.defineProperty(
|
||||||
|
window,
|
||||||
|
'IntersectionObserver',
|
||||||
|
originalIntersectionObserver,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(window, 'IntersectionObserver');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user