Skip to content

test(joint-react): add repro for useCombinedRef assigning the ref after layout effects - #3444

Open
kumilingus wants to merge 1 commit into
clientIO:masterfrom
kumilingus:test/combined-ref-layout-effect-timing
Open

test(joint-react): add repro for useCombinedRef assigning the ref after layout effects#3444
kumilingus wants to merge 1 commit into
clientIO:masterfrom
kumilingus:test/combined-ref-layout-effect-timing

Conversation

@kumilingus

Copy link
Copy Markdown
Contributor

Repro only — no production code touched. Adds src/hooks/__tests__/use-combined-ref-timing.test.tsx.

The defect

useCombinedRef assigns the forwarded ref inside a passive effect:

export function useCombinedRef<T>(ref?: ForwardedRef<T>): RefObject<T | null> {
  const innerRef = useRef<T>(null);
  useEffect(() => {
    setForwardRef(ref, innerRef.current);
  }, [ref]);
  return innerRef;
}

React attaches refs during commit, before layout effects run, and consumers are entitled to rely on that. Deferring the assignment means a parent that renders <SVGText ref={myRef} /> and reads myRef.current in its own useLayoutEffect sees null, because parent layout effects run before any passive effect.

useMeasureElement is exactly that consumer:

useLayoutEffect(() => {
  const element = nodeRef.current;
  if (!element) return;               // <- takes this branch on first commit
  ...
  const clean = setMeasuredNode({ id, node: nodeRef.current, transform });
  return () => { clean(); };
}, [nodeRef, graph, id, paper, setMeasuredNode]);

It returns before setMeasuredNode, and its dependency list contains nothing that changes when the node is finally assigned — so the node is never registered with the size observer and the element stays 0×0 permanently. No size, and no layout for anything that consumes sizes.

Why nothing caught it

  1. StrictMode remounts effects, so a second pass finds the ref already set by the first pass. It is on for every story (.storybook/preview.ts) and for every test (configure({ reactStrictMode: true }) in __mocks__/jest-setup.ts). The suite is blind to this class of bug by default, which is why the new test has to pass reactStrictMode: false explicitly.
  2. No example passes a ref to SVGText. The flowchart, svg-node and portal-selectors stories put a plain DOM ref on a native <text>; the SVGText story measures a <g> wrapper. The broken path is never exercised.

The existing use-combined-ref.test.tsx cases assert ref.current after render() returns, by which point passive effects have flushed — so they pass regardless. The new file keeps one such assertion, labelled, to make that explicit.

How it was found

An app that measured SVGText directly: correct under vite dev, and in the production build every node rendered as a bare label with no shape behind it and no layout. Tracing ResizeObserver in the page showed dev observing all ten text nodes and the production build observing none.

The tests

  • assigns the forwarded ref before a consumer layout effect runs — marked it.failing, so it documents the defect without reddening CI and starts failing once the hook is fixed (drop the .failing then).
  • is masked by StrictMode, which is why nothing caught it — asserts only the final value, so it holds before and after a fix.
  • has assigned the ref by the time render returns… — shows why the current tests pass.

Verified in both directions: as it stands the first body throws (all three green); with useCombinedRef switched to assign during commit, only that test flips and the other two stay green. So it pins this defect specifically.

Possible fix

Assign during commit rather than in an effect — a callback ref, or a proxy whose setter forwards:

const proxy = useMemo(() => ({
  get current() { return innerRef.current; },
  set current(value: T | null) { innerRef.current = value; setForwardRef(ref, value); },
}), [ref]);

Hardening useMeasureElement so a late ref is still picked up would be worth doing too, but the contract violation is in useCombinedRef. Happy to turn this into a fix PR if you'd like it in the same change.

🤖 Generated with Claude Code

…t effects

`useCombinedRef` assigns the forwarded ref inside a passive effect:

    useEffect(() => { setForwardRef(ref, innerRef.current); }, [ref]);

React attaches refs during commit, before layout effects run, and
consumers are entitled to rely on that. Because this defers the
assignment, a parent that renders `<SVGText ref={myRef} />` and reads
`myRef.current` in its own `useLayoutEffect` sees null — parent layout
effects run before any passive effect.

`useMeasureElement` is exactly that consumer: it reads `nodeRef.current`
in a layout effect, returns early when empty, and its dependency list
contains nothing that changes when the node is finally assigned, so the
node is never registered with the size observer and the element stays 0x0
for good.

Two things hide it. StrictMode remounts effects, so a second pass finds
the ref already set — and it is on for every story and, via
`configure({ reactStrictMode: true })`, for every test, so the suite is
blind to this by default and the repro has to opt out with
`reactStrictMode: false`. Separately, no example in the package passes a
ref to `SVGText`: the flowchart, svg-node and portal-selectors stories put
a plain DOM ref on a native `<text>`, and the `SVGText` story measures a
`<g>` wrapper.

Found in an app measuring `SVGText` directly, where it was correct in
development and every node lost its shape and layout in the production
build.

The first test is `it.failing`, so this documents the defect without
reddening CI and starts failing once the hook is fixed. Verified both
ways: as it stands the body throws, and with `useCombinedRef` switched to
assign during commit only that test flips, leaving the other two green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kumilingus
kumilingus requested a review from samuelgja August 3, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant