Skip to content

fix(react): handle ref changes across rerenders before hydration#2500

Merged
upupming merged 1 commit intomainfrom
fix/ref-apply-dedup
Apr 24, 2026
Merged

fix(react): handle ref changes across rerenders before hydration#2500
upupming merged 1 commit intomainfrom
fix/ref-apply-dedup

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Apr 21, 2026

Bug

In BackgroundSnapshotInstance.setAttribute, the pre-hydration branch (__globalSnapshotPatch falsy) always passed null as the old ref to queueRefAttrUpdate. So when a rerender happened before hydration (e.g. useEffectsetState), the old ref was never detached and identical refs were re-applied instead of short-circuiting. Post-hydration setAttributeImpl already did this correctly — the two paths diverged.

Fix

Extract old/new refs symmetrically via a shared getRefFromValue(val) helper (handles both __ref and __spread-with-ref) and let queueRefAttrUpdate decide identity/clear/apply. Hot-path setAttributeImpl is intentionally untouched.

testing-library/src/pure.jsx also moves flushDelayedLifecycleEvents() out of act() so useEffect fires before rLynxFirstScreen hydration — matching the real lifecycle.

Tests

Package Scenario
runtime fn ref → different fn ref
runtime fn ref → null
runtime null → fn ref
runtime spread-with-ref → spread-without-ref
runtime spread-without-ref → spread-with-ref
runtime object ref (createRef) → different object ref
runtime object ref → null
runtime null → object ref
runtime same callback in spread form (short-circuit)
runtime 3 consecutive rerenders (intermediate cleanup)
testing-library same callback across rerenders (invoked once)
testing-library normal/spread × normal/spread matrix (4 cases)

Test plan

  • pnpm -F @lynx-js/react-runtime test — 560 pass, 100% coverage on backgroundSnapshot.ts
  • pnpm -F @lynx-js/react-testing-library test — 104 pass
  • CI green

Summary by CodeRabbit

  • Bug Fixes

    • Fixed ref callback lifecycle so previous callbacks are reliably removed and new ones applied during rerenders that occur before hydration.
  • Tests

    • Added comprehensive tests covering function refs, object refs, spread refs, and multi-step pre-hydration rerender scenarios.
  • Chores

    • Added a patch changeset and adjusted test harness lifecycle ordering for background render paths.

@upupming upupming requested review from HuJean, Yradex and hzy as code owners April 21, 2026 15:47
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: 3975ffb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@lynx-js/react Patch
@lynx-js/react-umd Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

Ref extraction and queuing in background pre-hydration renders was refactored: a new getRefFromValue helper was added and setAttribute('values', ...) now derives and passes both old and new refs into queueRefAttrUpdate for correct cleanup/re-application across rerenders.

Changes

Cohort / File(s) Summary
Core ref logic
packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts, packages/react/runtime/src/snapshot/snapshot/ref.ts
Add getRefFromValue(val) to extract refs from value shapes (__spread+ref, __ref). Update setAttribute('values', ...) to always compute old/new refs from previous per-index __values and current value, and pass both into queueRefAttrUpdate.
Snapshot tests
packages/react/runtime/__test__/snapshot/ref.test.jsx
Add tests covering ref lifecycle during pre-hydration background rerenders: callback removal (null) and re-application with RefProxy, spread-ref behavior, stability when same ref reused, and multi-step rerender cleanup.
Integration tests
packages/react/testing-library/src/__tests__/ref.test.jsx
Extend tests to validate ref callback invocation semantics across pre-hydration rerenders, including direct and spread refs and host-capture via useRef/useEffect.
Testing infra
packages/react/testing-library/src/pure.jsx
Move flushDelayedLifecycleEvents() out of the act(...) callback to run immediately after act, adjusting lifecycle flush ordering relative to effects.
Release notes
.changeset/fix-react-ref-before-hydration.md
Add changeset declaring a patch release for @lynx-js/react describing the ref callback cleanup/re-application fix.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • hzy
  • Yradex
  • HuJean

Poem

🐰 I nibble at refs in the pre-hydration light,
Old callbacks waved off, new ones held tight,
getRefFromValue finds what was tucked away,
Rerenders tidy, each ref knows its play. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: handling ref callback cleanup and re-application when refs change between rerenders before hydration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ref-apply-dedup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react/runtime/src/snapshot/backgroundSnapshot.ts (1)

329-339: ⚠️ Potential issue | 🟡 Minor

Optional: Minor readability improvement — asymmetric optional chaining.

The v !== oldV guard is correct and prevents redundant ref queue calls. Practically speaking, the oldV read here is always undefined because:

  1. Call path analysis: All production paths calling setAttribute('values', …) either explicitly null __values first (lines 723–724 in reconstructInstanceTree, line 140 in snapshot.ts) or occur during initial setup before __values is populated.
  2. Branch coverage: This else branch runs only when __globalSnapshotPatch is null/undefined; patch-apply paths (where patches might contain pre-populated refs) initialize __globalSnapshotPatch first, so they take the first branch.
  3. No ref swap risk: The concern about passing null instead of oldV's ref is not a practical issue here because oldV is always undefined.

That said, the read-side optional chaining (this.__values as unknown[])?.[index] is asymmetric with the unconditional write this.__values = value as unknown[] on line 341, which is a minor readability smell. Consider dropping the optional chaining for consistency:

const oldV = (this.__values as unknown[])[index];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/snapshot/backgroundSnapshot.ts` around lines 329 -
339, In the forEach over this.__snapshot_def.refAndSpreadIndexes, remove the
asymmetric optional chaining when reading oldV so it matches the unconditional
write to this.__values; specifically change the read of this.__values to use
(this.__values as unknown[])[index] (referenced in the loop where oldV is
assigned) so the access style is consistent with the later unconditional
assignment to this.__values and improves readability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/react/runtime/src/snapshot/backgroundSnapshot.ts`:
- Around line 329-339: In the forEach over
this.__snapshot_def.refAndSpreadIndexes, remove the asymmetric optional chaining
when reading oldV so it matches the unconditional write to this.__values;
specifically change the read of this.__values to use (this.__values as
unknown[])[index] (referenced in the loop where oldV is assigned) so the access
style is consistent with the later unconditional assignment to this.__values and
improves readability.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 12841f3c-280c-4b85-80de-b7f537b2671e

📥 Commits

Reviewing files that changed from the base of the PR and between d76a499 and ce0fb92.

📒 Files selected for processing (3)
  • .changeset/fix-react-ref-apply-dedup.md
  • packages/react/runtime/src/snapshot/backgroundSnapshot.ts
  • packages/react/testing-library/src/__tests__/ref-dedup.test.jsx

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 21, 2026

Merging this PR will degrade performance by 24.98%

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 79 untouched benchmarks
⏩ 26 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
002-hello-reactLynx-destroyBackground 670.1 µs 893.2 µs -24.98%
008-many-use-state-destroyBackground 9.5 ms 8 ms +18.48%

Comparing fix/ref-apply-dedup (3975ffb) with main (8352530)

Open in CodSpeed

Footnotes

  1. 26 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React External

#694 Bundle Size — 680.2KiB (+0.04%).

3975ffb(current) vs 8352530 main#686(baseline)

Bundle metrics  Change 1 change
                 Current
#694
     Baseline
#686
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 39.67% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 17 17
No change  Duplicate Modules 5 5
No change  Duplicate Code 8.59% 8.59%
No change  Packages 0 0
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#694
     Baseline
#686
Regression  Other 680.2KiB (+0.04%) 679.93KiB

Bundle analysis reportBranch fix/ref-apply-dedupProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React MTF Example

#708 Bundle Size — 196.47KiB (+0.04%).

3975ffb(current) vs 8352530 main#700(baseline)

Bundle metrics  Change 1 change
                 Current
#708
     Baseline
#700
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 43.36% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 173 173
No change  Duplicate Modules 66 66
No change  Duplicate Code 44.07% 44.07%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#708
     Baseline
#700
No change  IMG 111.23KiB 111.23KiB
Regression  Other 85.24KiB (+0.1%) 85.15KiB

Bundle analysis reportBranch fix/ref-apply-dedupProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React Example

#7576 Bundle Size — 225.31KiB (+0.04%).

3975ffb(current) vs 8352530 main#7568(baseline)

Bundle metrics  Change 1 change
                 Current
#7576
     Baseline
#7568
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 35.28% 0%
No change  Chunks 0 0
No change  Assets 4 4
No change  Modules 179 179
No change  Duplicate Modules 69 69
No change  Duplicate Code 44.57% 44.57%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#7576
     Baseline
#7568
No change  IMG 145.76KiB 145.76KiB
Regression  Other 79.55KiB (+0.1%) 79.47KiB

Bundle analysis reportBranch fix/ref-apply-dedupProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

Web Explorer

#9148 Bundle Size — 900.04KiB (0%).

3975ffb(current) vs 8352530 main#9140(baseline)

Bundle metrics  Change 1 change
                 Current
#9148
     Baseline
#9140
No change  Initial JS 44.46KiB 44.46KiB
No change  Initial CSS 2.22KiB 2.22KiB
Change  Cache Invalidation 0% 8.1%
No change  Chunks 9 9
No change  Assets 11 11
Change  Modules 229(+0.44%) 228
No change  Duplicate Modules 11 11
No change  Duplicate Code 27.28% 27.28%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#9148
     Baseline
#9140
No change  JS 495.9KiB 495.9KiB
No change  Other 401.92KiB 401.92KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch fix/ref-apply-dedupProject dashboard


Generated by RelativeCIDocumentationReport issue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react/runtime/__test__/snapshot/ref-dedup.test.jsx`:
- Around line 186-224: The test needs an intervening different render to force a
re-diff so the sibling-ref `v !== oldV` guard is exercised; modify the test
around the second re-render (where deinitGlobalSnapshotPatch(),
ref1.mockClear(), ref2.mockClear(), render(<Comp />, __root) currently occur) to
insert a short different render (for example call render(<view />, __root) or
render(<div /> , __root)) before re-rendering <Comp />. This change touches the
test's use of render, __root, Comp, deinitGlobalSnapshotPatch and the ref1/ref2
expectations and will ensure the runtime performs a values attribute update
rather than skipping it.
- Around line 146-184: The test "re-applies when the callback ref identity
actually changes" currently only asserts refB attached but doesn't verify the
old callback ref was released; update the assertions after swapping currentRef
to refB and rendering tick=1 to also assert that refA was invoked with null
(e.g., that refA received a null argument when detached) to ensure the old ref
is cleared when identity changes; reference the mock functions refA and refB,
the currentRef variable, and the render(<Comp tick={1} />, __root) call when
adding this assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0905a691-17aa-4808-ad34-30f89821358c

📥 Commits

Reviewing files that changed from the base of the PR and between ce0fb92 and 6441d1e.

📒 Files selected for processing (1)
  • packages/react/runtime/__test__/snapshot/ref-dedup.test.jsx

Comment thread packages/react/runtime/__test__/snapshot/ref-dedup.test.jsx Outdated
Comment thread packages/react/runtime/__test__/snapshot/ref-dedup.test.jsx Outdated
@upupming upupming force-pushed the fix/ref-apply-dedup branch from 3822e86 to 1750a68 Compare April 23, 2026 13:11
@upupming upupming changed the title fix(react): dedup ref apply on stable refs fix(react): handle ref changes across rerenders before hydration Apr 23, 2026
@upupming upupming force-pushed the fix/ref-apply-dedup branch from 1750a68 to 79716c0 Compare April 23, 2026 13:12
@cla-assistant
Copy link
Copy Markdown

cla-assistant Bot commented Apr 23, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/react/runtime/__test__/snapshot/ref.test.jsx (1)

1982-2153: LGTM — thorough unit coverage at the snapshot layer.

The suite correctly exercises identity short-circuiting, null transitions in both directions, spread forms, object refs via createRef, and the three-rerender intermediate cleanup case. Minor nit: the new it(...) blocks use async function() but none of them await — converting to sync function() (matching the plain tests) would be slightly clearer, but it's cosmetic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/__test__/snapshot/ref.test.jsx` around lines 1982 -
2153, Tests use async function() in multiple it(...) blocks though no awaits are
used; change those to plain synchronous function() to match other tests. Edit
the it(...) declarations (e.g., the cases titled 'ref is changed across
rerenders before hydration', 'ref becomes null on rerender before hydration',
'ref is added on rerender before hydration', 'spread ref is removed on rerender
before hydration', 'spread ref is added on rerender before hydration', 'object
ref (createRef) is changed...', 'object ref (createRef) becomes null...',
'object ref (createRef) is added...', 'same ref callback in spread form should
not be re-invoked', 'three consecutive rerenders before hydration clean up
intermediate refs') and replace "async function()" with "function()" so the test
signatures are synchronous.
packages/react/testing-library/src/__tests__/ref.test.jsx (1)

487-572: LGTM — good matrix coverage for the pre-hydration ref fix.

The normal×spread matrix plus the same-callback short-circuit and useRef+useEffect portal-host pattern closely mirror the real-world regressions the fix targets. Consider also asserting the cleanup return-value path (a ref callback that returns a function) across a rerender before hydration to pin down _unmount semantics from applyRef — that's the one branch in applyRef not directly exercised here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/testing-library/src/__tests__/ref.test.jsx` around lines 487 -
572, Add a test exercising the ref-callback cleanup path that `applyRef`
handles: create a component like the existing App patterns that triggers a
pre-hydration rerender (useState + useEffect bump) and use a ref callback that
returns a cleanup function (e.g., const cb = vi.fn(() => cleanupFn)). Assert
that the cleanup function is invoked when the ref is removed/changed (old
callback called with null and its returned cleanup called once) and that the new
ref receives the host node; reference the existing test helpers App, render, and
the ref callbacks (oldCb/newCb) to mirror the normal×spread matrix setup so the
`_unmount`/cleanup branch in applyRef is covered.
packages/react/runtime/src/snapshot/snapshot/ref.ts (1)

83-94: LGTM — helper correctly covers both ref encodings.

getRefFromValue symmetrically handles the __spread+ref and __ref forms and returns null for anything else, which is exactly what queueRefAttrUpdate needs to short-circuit identity on same-ref rerenders. One optional follow-up: the post-hydration setAttributeImpl in backgroundSnapshot.ts (lines 403, 412–423, 429–431, 464–466) still inlines the same pattern-matching. Routing those through getRefFromValue too would eliminate the pre/post divergence that caused this bug in the first place and keep the two paths from drifting again.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/snapshot/snapshot/ref.ts` around lines 83 - 94,
The post-hydration setAttributeImpl implementation in backgroundSnapshot.ts
duplicates the ref-pattern matching logic; replace those inline checks with a
call to getRefFromValue so both pre-hydration and post-hydration paths use the
same ref extraction logic. Locate setAttributeImpl (the post-hydration variant
that currently inspects __spread/ref and __ref inline) and swap the manual
pattern matching with getRefFromValue(value) and use its null/ref result for the
identity short-circuiting and subsequent handling, ensuring behavior is
consistent with queueRefAttrUpdate and preventing drift between the two paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react/runtime/__test__/snapshot/ref.test.jsx`:
- Around line 1982-2153: Tests use async function() in multiple it(...) blocks
though no awaits are used; change those to plain synchronous function() to match
other tests. Edit the it(...) declarations (e.g., the cases titled 'ref is
changed across rerenders before hydration', 'ref becomes null on rerender before
hydration', 'ref is added on rerender before hydration', 'spread ref is removed
on rerender before hydration', 'spread ref is added on rerender before
hydration', 'object ref (createRef) is changed...', 'object ref (createRef)
becomes null...', 'object ref (createRef) is added...', 'same ref callback in
spread form should not be re-invoked', 'three consecutive rerenders before
hydration clean up intermediate refs') and replace "async function()" with
"function()" so the test signatures are synchronous.

In `@packages/react/runtime/src/snapshot/snapshot/ref.ts`:
- Around line 83-94: The post-hydration setAttributeImpl implementation in
backgroundSnapshot.ts duplicates the ref-pattern matching logic; replace those
inline checks with a call to getRefFromValue so both pre-hydration and
post-hydration paths use the same ref extraction logic. Locate setAttributeImpl
(the post-hydration variant that currently inspects __spread/ref and __ref
inline) and swap the manual pattern matching with getRefFromValue(value) and use
its null/ref result for the identity short-circuiting and subsequent handling,
ensuring behavior is consistent with queueRefAttrUpdate and preventing drift
between the two paths.

In `@packages/react/testing-library/src/__tests__/ref.test.jsx`:
- Around line 487-572: Add a test exercising the ref-callback cleanup path that
`applyRef` handles: create a component like the existing App patterns that
triggers a pre-hydration rerender (useState + useEffect bump) and use a ref
callback that returns a cleanup function (e.g., const cb = vi.fn(() =>
cleanupFn)). Assert that the cleanup function is invoked when the ref is
removed/changed (old callback called with null and its returned cleanup called
once) and that the new ref receives the host node; reference the existing test
helpers App, render, and the ref callbacks (oldCb/newCb) to mirror the
normal×spread matrix setup so the `_unmount`/cleanup branch in applyRef is
covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 89d937d3-6b53-448b-b9ec-c760cae78d25

📥 Commits

Reviewing files that changed from the base of the PR and between 2c3adc3 and 79716c0.

📒 Files selected for processing (6)
  • .changeset/fix-react-ref-before-hydration.md
  • packages/react/runtime/__test__/snapshot/ref.test.jsx
  • packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
  • packages/react/runtime/src/snapshot/snapshot/ref.ts
  • packages/react/testing-library/src/__tests__/ref.test.jsx
  • packages/react/testing-library/src/pure.jsx
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-react-ref-before-hydration.md

When a rerender happens before hydration (e.g. a `useEffect` that
triggers `setState` during the initial background render), the
no-snapshot-patch branch of `BackgroundSnapshotInstance.setAttribute`
used to always pass `null` as the old ref to `queueRefAttrUpdate`.
As a result, the old ref was never invoked with `null` when it should
have been detached, and identical ref identities were re-applied each
render instead of being short-circuited.

Extract old and new refs symmetrically via a shared `getRefFromValue`
helper for both normal (`<view ref={…} />`) and spread
(`<view {...{ ref }} />`) slot forms, and let `queueRefAttrUpdate`
handle identity/clear/apply decisions.
@upupming upupming force-pushed the fix/ref-apply-dedup branch from 79716c0 to 3975ffb Compare April 23, 2026 14:13
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/react/testing-library/src/__tests__/ref.test.jsx (1)

487-572: Good coverage of the regression.

The matrix over normal/spread forms plus the useRef + useEffect host-capture test nicely exercises the real-world useEffect→setState→rerender-before-hydration path that the fix targets. The assertions on call counts and the null cleanup call are precise.

Minor nit (optional): in the seenHosts test (Line 557–572), consider also asserting seenHosts.mock.calls[0][0] is the expected RefProxy to catch accidental null captures; as written, seenHosts only gates on truthy host, so a regression that always captures null would silently pass the call-count check. Not a blocker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/testing-library/src/__tests__/ref.test.jsx` around lines 487 -
572, The test "useRef + useEffect + setState host capture is stable (portal-host
pattern)" only asserts seenHosts call count which can miss a regression that
captures null; update the App test to also assert the captured host is the
expected RefProxy shape by checking seenHosts.mock.calls[0][0] (referencing the
seenHosts mock and the hostRef/useRef in App) equals or matches an object with
ref-like structure (e.g., not null and has the refAttr array used elsewhere in
this file), so the test fails if host is null or not the expected RefProxy.
packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts (1)

363-371: Fix correctly symmetrizes old/new ref extraction in the pre-hydration branch.

Passing getRefFromValue(oldValue) instead of null is exactly the missing piece — queueRefAttrUpdate now has the information needed to detach the previous ref and short-circuit when identity is unchanged, matching the behavior of setAttributeImpl on the post-hydration path. The this.__values?.[index] guard also correctly handles the first-render case where __values is still undefined.

One small thought (not blocking): setAttributeImpl still performs its own ad-hoc extraction at lines 403–404, 418–423, 430, 464–466. Since getRefFromValue now exists, a follow-up could consolidate those call sites to use the same helper for consistency, reducing the chance of the two paths drifting again. Leaving as-is for this PR is fine given the stated scope of not touching the hot path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts` around
lines 363 - 371, In the pre-hydration branch that iterates
this.__snapshot_def.refAndSpreadIndexes, ensure old ref extraction is
symmetrized by using getRefFromValue(this.__values?.[index]) (i.e., pass
getRefFromValue(oldValue) rather than null) so queueRefAttrUpdate receives the
previous ref for proper detach/identity checks; keep the this.__values?.[index]
guard to handle first-render undefined __values and call
queueRefAttrUpdate(getRefFromValue(oldValue), getRefFromValue(v), this.__id,
index).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts`:
- Around line 363-371: In the pre-hydration branch that iterates
this.__snapshot_def.refAndSpreadIndexes, ensure old ref extraction is
symmetrized by using getRefFromValue(this.__values?.[index]) (i.e., pass
getRefFromValue(oldValue) rather than null) so queueRefAttrUpdate receives the
previous ref for proper detach/identity checks; keep the this.__values?.[index]
guard to handle first-render undefined __values and call
queueRefAttrUpdate(getRefFromValue(oldValue), getRefFromValue(v), this.__id,
index).

In `@packages/react/testing-library/src/__tests__/ref.test.jsx`:
- Around line 487-572: The test "useRef + useEffect + setState host capture is
stable (portal-host pattern)" only asserts seenHosts call count which can miss a
regression that captures null; update the App test to also assert the captured
host is the expected RefProxy shape by checking seenHosts.mock.calls[0][0]
(referencing the seenHosts mock and the hostRef/useRef in App) equals or matches
an object with ref-like structure (e.g., not null and has the refAttr array used
elsewhere in this file), so the test fails if host is null or not the expected
RefProxy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 060aa897-b12d-43c4-a0fc-b68f57a63e30

📥 Commits

Reviewing files that changed from the base of the PR and between 79716c0 and 3975ffb.

📒 Files selected for processing (6)
  • .changeset/fix-react-ref-before-hydration.md
  • packages/react/runtime/__test__/snapshot/ref.test.jsx
  • packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
  • packages/react/runtime/src/snapshot/snapshot/ref.ts
  • packages/react/testing-library/src/__tests__/ref.test.jsx
  • packages/react/testing-library/src/pure.jsx
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-react-ref-before-hydration.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react/testing-library/src/pure.jsx

@lynx-family lynx-family deleted a comment from cla-assistant Bot Apr 24, 2026
@upupming upupming merged commit 57c7fa3 into main Apr 24, 2026
104 of 111 checks passed
@upupming upupming deleted the fix/ref-apply-dedup branch April 24, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants