Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/hooks/useMarkViewDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export function useMarkViewDescription(

const contentDOMRef = useRef<HTMLElement | null>(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<MarkViewDesc | undefined>();
const childrenRef = useRef<ViewDesc[]>([]);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
28 changes: 26 additions & 2 deletions src/hooks/useNodeViewDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ export function useNodeViewDescription(
const { parentRef, siblingsRef } = useContext(ChildDescriptionsContext);
const contentDOMRef = useRef<HTMLElement | null>(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<NodeViewDesc | undefined>();
const childrenRef = useRef<ViewDesc[]>([]);

Expand Down Expand Up @@ -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()) {
Expand Down
Loading