预览可见性门禁等 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:
2026-09-11 15:31:29 +08:00
parent 39a96aa418
commit 2a157ea6f8
2 changed files with 416 additions and 6 deletions
@@ -595,10 +595,48 @@ export function useProjectResourceCardPreviews(input: {
[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(
(element: HTMLElement, resource: ProjectResource, identity: string) => {
const binding = { resource, identity };
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);
return () => {
observerRef.current?.unobserve(element);
@@ -607,7 +645,7 @@ export function useProjectResourceCardPreviews(input: {
}
};
},
[],
[input.canvasRef, input.intersectionRootRef],
);
const failPreview = useCallback(
@@ -755,6 +793,21 @@ export function useProjectResourceCardPreviews(input: {
if (!IntersectionObserverClass) {
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(
(entries) => {
for (const entry of entries) {
@@ -770,21 +823,30 @@ export function useProjectResourceCardPreviews(input: {
}
},
{
root: input.intersectionRootRef?.current ?? input.canvasRef.current,
root,
rootMargin: '160px',
},
);
observerRef.current = observer;
for (const element of observedCardsRef.current.keys()) {
observer.observe(element);
}
observerRootRef.current = root;
attachObservedCards();
return () => {
observer.disconnect();
if (observerRef.current === observer) {
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 {
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');
}
}
});
});