预览可见性门禁等 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],
|
||||
);
|
||||
|
||||
/**
|
||||
* 把当前登记表里的全部卡片元素补挂到 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user