diff --git a/src/hooks/useMarkViewDescription.ts b/src/hooks/useMarkViewDescription.ts index 8768556..75969dd 100644 --- a/src/hooks/useMarkViewDescription.ts +++ b/src/hooks/useMarkViewDescription.ts @@ -30,6 +30,11 @@ export function useMarkViewDescription( const contentDOMRef = useRef(null); + // Tracks the mount layout effect's lifecycle, as in + // useNodeViewDescription: refUpdated must be inert between that + // effect's cleanup and its next run, and must still run after a + // callback ref churn, which a viewDescRef guard alone gets wrong. + const mountedRef = useRef(false); const viewDescRef = useRef(); const childrenRef = useRef([]); @@ -73,6 +78,17 @@ export function useMarkViewDescription( child.parent = viewDesc; } + // Register with the parent's children immediately, as the node view + // hook does. create() can run from a ref callback (refUpdated) + // without a following layout effect, and a live desc that is absent + // from its parent's children corrupts position mapping + // (posBeforeChild walks past the end of the array). + const siblings = siblingsRef.current; + if (!siblings.includes(viewDesc)) { + siblings.push(viewDesc); + } + siblings.sort(sortViewDescs); + contentDOMRef.current = contentDOM; return viewDesc; @@ -121,18 +137,27 @@ export function useMarkViewDescription( }); useClientLayoutEffect(() => { + mountedRef.current = true; viewDescRef.current = create(); return () => { + mountedRef.current = false; destroy(); }; }, [create, destroy]); + // A null DOM on a ref detach pass while mounted is transient, as in + // useNodeViewDescription: the attach pass re-syncs the desc, and a + // real unmount destroys it in the layout effect cleanup. + const domDetached = useEffectEvent(() => getDOM() == null); + const refUpdated = useCallback(() => { + if (!mountedRef.current) return; + if (domDetached()) return; if (!update()) { destroy(); viewDescRef.current = create(); } - }, [create, destroy, update]); + }, [create, destroy, domDetached, update]); useClientLayoutEffect(() => { if (!update()) { diff --git a/src/hooks/useNodeViewDescription.ts b/src/hooks/useNodeViewDescription.ts index 5c10e0b..884bfc6 100644 --- a/src/hooks/useNodeViewDescription.ts +++ b/src/hooks/useNodeViewDescription.ts @@ -29,6 +29,19 @@ export function useNodeViewDescription( const { parentRef, siblingsRef } = useContext(ChildDescriptionsContext); const contentDOMRef = useRef(null); + // Tracks the mount layout effect's lifecycle. refUpdated must be inert + // between that effect's cleanup and its next run: React detaches and + // reattaches callback refs around a simulated remount (StrictMode, + // Activity), and in that window viewDescRef still points at the + // destroyed desc, so update() can misjudge it and register a + // replacement that the effect's unconditional create() then orphans in + // the parent's children, corrupting position mapping. Guarding on + // viewDescRef alone gets the other direction wrong: a ref reattach + // after callback ref churn (#276) finds it empty and never recreates + // the desc. While mounted, every destroy is immediately followed by a + // create, so viewDescRef only ever holds a live desc and update() can + // be trusted. + const mountedRef = useRef(false); const viewDescRef = useRef(); const childrenRef = useRef([]); @@ -139,19 +152,30 @@ export function useNodeViewDescription( }); useClientLayoutEffect(() => { + mountedRef.current = true; viewDescRef.current = create(); return () => { + mountedRef.current = false; destroy(); }; }, [create, destroy]); + // React detaches a replaced callback ref by calling it with null + // before attaching its successor, so while mounted a null DOM here is + // transient: the attach pass that follows re-syncs the desc, and a + // real unmount destroys it in the layout effect cleanup. Tearing the + // desc down on the detach pass would destroy and recreate it even + // though the DOM never changed. + const domDetached = useEffectEvent(() => getDOM() == null); + const refUpdated = useCallback(() => { - if (!viewDescRef.current) return; + if (!mountedRef.current) return; + if (domDetached()) return; if (!update()) { destroy(); viewDescRef.current = create(); } - }, [create, destroy, update]); + }, [create, destroy, domDetached, update]); useClientLayoutEffect(() => { if (!update()) {