Skip to content

fix(react-start-client): call HydrationBoundary hooks before the visible() gate - #8653

Merged
Sheraff merged 5 commits into
TanStack:mainfrom
ethan-huo:fix/visible-hydration-hook-order
Nov 3, 2026
Merged

Sheraff merged 5 commits into
TanStack:mainfrom
ethan-huo:fix/visible-hydration-hook-order

Conversation

@ethan-huo

@ethan-huo ethan-huo commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

🎯 Changes

Fixes #8652. The fix and its root-cause analysis come from this PR's original commit (9b225fd). A maintainer took the PR over to align it with the compiler rewrite (#8817), which carries the same fix.

The bug

<Hydrate when={visible()}> renders HydrationBoundary, which suspends on its gate with if (!g.r) use(g.p) and called useEffect only after that check. React replays a component that suspended in use with its re-render dispatcher, and only use switches away from it. When the intersection arrives while React waits to replay, g.r is already true, so the replay skips use and reaches a hook the first attempt never called. React throws "Update hook called on initial render" (#467 in production). During hydration React then drops the boundary's server HTML and client-renders it: the fallback replaces the markup until the split code loads, and onRecoverableError reports the error.

The fix

packages/react-start-client/src/hydration/visible.tsx: the effect moves above the gate, so every attempt calls the same hooks. Effects run only after commit, so onHydrated still fires once, after the boundary renders its children.

The component destructures only o and reads props.g inline. Keeping const { g, o } = props after the reorder costs +6 B gzip on react-start.deferred-hydration. The file is now identical to #8817's.

The test

src/tests/Hydrate.test.tsx, "hydrates a visible boundary whichever task the intersection arrives in": it server-renders a visible() boundary, hydrates that HTML with hydrateRoot, and fires the intersection in each of the 12 tasks around the first hydration attempt. It asserts that every run hydrates (onHydrated resolves) with no recoverable error. It fails on main (the error appears when the intersection lands one task in) and passes with the fix.

This test replaces the earlier src/tests/visible.test.tsx, which also failed on main. That test triggered the replay from a client-only createRoot render through a side effect during render. The hydration test covers the case users hit (server HTML being dropped), doesn't depend on one scheduling trick, and is the same test #8817 has. Keeping both would test the same thing twice.

The changeset is renamed to .changeset/visible-hydrate-replay.md, with #8817's wording (patch).

Validation

  • @tanstack/react-start-client: test:unit, test:types, test:eslint, test:build pass.
  • E2E: tanstack-react-start-e2e-deferred-hydration (16 tests, dev and prod) and tanstack-react-start-e2e-rsc-deferred-hydration (3 tests, dev and prod) pass.

Bundle size (client, against main at ad5b3b7a09)

Scenario gzip raw brotli
react-start.deferred-hydration +0 B +2 B +7 B
the other 17 scenarios 0 0 0

The original shape (const { g, o } = props after the reorder) measured +6 B gzip, +0 raw and −8 B brotli on react-start.deferred-hydration. These are the same numbers #8817 reports for its visible() fix.

Relation to #8817

The three files here are byte-identical to #8817's (visible.tsx, the Hydrate.test.tsx test, the changeset). A test merge of this branch into main followed by #8817's head adds no conflicts: #8817's conflict set with main stays the same 19 unrelated files. After this lands, #8817 only needs to drop the visible() bundle-size note from its description.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed visible hydration boundaries retaining server-rendered content when visibility is detected during React’s replay of the boundary render.

@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 47b3841

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

This PR includes changesets to release 2 packages
Name Type
@tanstack/react-start-client Patch
@tanstack/react-start 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

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Walkthrough

HydrationBoundary now declares its onHydrated effect before checking or suspending on the visibility gate. A regression test exercises delayed intersections during hydration and checks for recoverable errors. A patch changeset records the fix.

Changes: Visible hydration boundary

Layer / File(s) Summary
Hook order and replay regression test
packages/react-start-client/src/hydration/visible.tsx, packages/react-start-client/src/tests/Hydrate.test.tsx, .changeset/visible-hydrate-replay.md
HydrationBoundary declares the onHydrated effect before the gate check and suspension path. The test checks hydration with intersections after 0 through 11 task delays and expects no recoverable errors. The changeset records a patch release for @tanstack/react-start-client.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related issues

  • TanStack/router#8778 — The issue concerns the closed gate in a deferred Hydrate boundary. This change moves the effect before the gate check but does not change the gate suspension behavior.

Suggested labels: package: react-start-client

Suggested reviewers: schiller-manuel

Merge Risk: Minimal · up to 47b38

The visibility-hydration fix has a regression test that completes hydration before checking for errors. No actionable merge-blocking issue remains.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly identifies the primary fix: moving HydrationBoundary hooks before the visible() gate.
Description check Passed The description includes the required Changes, Checklist, and Release Impact sections. It explains the bug, fix, tests, validation results, release impact, and AI assistance disclosure.
Linked Issues check Passed The PR addresses the only active directly linked issue, #8652. The reviewed head moves the React.useEffect call in HydrationBoundary (packages/react-start-client/src/hydration/visible.tsx) above…
Out of Scope Changes check Passed The whole-PR diff against the merge base contains three changed files, and each supports #8652. visible.tsx contains only the hook-order change that fixes the reported #467 replay. `Hydrate.test.tsx…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Full details: Linked Issues check

Explanation

The PR addresses the only active directly linked issue, #8652. The reviewed head moves the React.useEffect call in HydrationBoundary (packages/react-start-client/src/hydration/visible.tsx) above the if (!props.g.r) gate and reactUse(props.g.p) call. Every render attempt now calls the same hooks in the same order, so a replay after use suspended cannot reach a hook the first attempt did not call. This is the fix the issue proposes. onHydrated still runs from the effect after commit, so it fires once after the children render.

The regression test moved from visible.test.tsx (removed at this head) to packages/react-start-client/src/tests/Hydrate.test.tsx, test "hydrates a visible boundary whichever task the intersection arrives in". It server-renders a visible() boundary, hydrates it with hydrateRoot, fires the intersection after 0 to 11 task delays, and asserts that every run calls onHydrated with no onRecoverableError errors. No recoverable error means React did not discard the server HTML (#422 path). The issue's stated expected behavior (no #467 during hydration, server HTML kept until hydration) is covered by this automated test.

The issue's original reproducer used a client-only createRoot mount path. The new test covers hydration only. The issue's expectation of no throw on mount is addressed by the same hook-order fix, but no test in this head exercises the mount-only path. This gap does not block the verdict because the issue's reproducer is an external fork link, and the hydration test covers the user-facing failure.

Full details: Out of Scope Changes check

Explanation

The whole-PR diff against the merge base contains three changed files, and each supports #8652. visible.tsx contains only the hook-order change that fixes the reported #467 replay. Hydrate.test.tsx adds the visible import and the regression test for that replay. The changeset (.changeset/visible-hydrate-replay.md, patch release for @tanstack/react-start-client) records the published fix. The earlier visible.test.tsx no longer exists at this head, so it cannot add out-of-scope changes. No unrelated change is demonstrated.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 1 functions across 2 files. (1 skipped: 1 unsupported.)

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between e2caccc and 47b3841.

Files selected for processing: 3
  • .changeset/visible-hydrate-replay.md
  • packages/react-start-client/src/hydration/visible.tsx
  • packages/react-start-client/src/tests/Hydrate.test.tsx

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Sheraff and others added 2 commits November 3, 2026 21:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd test it through hydration

- Destructure only `o` and read `props.g` inline: keeps the deferred-hydration
  client gzip at main's (+6 B with both destructured after the reorder).
- Replace the client-render test with a hydration test that runs the
  intersection in each task around the boundary's first hydration attempt and
  asserts no recoverable error. It fails on main.
- Use the changeset wording of TanStack#8817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nx-cloud

nx-cloud Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 47b3841

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 10m 26s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 52s View ↗

☁️ Nx Cloud last updated this comment at 2026-11-03 20:28:12 UTC

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@8653

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@8653

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@8653

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@8653

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@8653

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@8653

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@8653

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@8653

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@8653

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@8653

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@8653

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@8653

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@8653

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@8653

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@8653

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@8653

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@8653

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@8653

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@8653

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@8653

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@8653

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@8653

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@8653

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@8653

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@8653

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@8653

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@8653

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@8653

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@8653

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@8653

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@8653

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@8653

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@8653

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@8653

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@8653

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@8653

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@8653

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@8653

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@8653

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@8653

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@8653

commit: 47b3841

@codspeed

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 0.84%

2 warnings, 1 note

⚠️ 48 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

ℹ️ 20 benchmarks have no flamegraph

The flamegraph wasn't generated: most of the time was spent in runtime internals, which would leave nothing useful to analyze.

See affected benchmarks

⚡ 1 improved benchmark
❌ 1 (👁 1) regressed benchmark
✅ 192 untouched benchmarks
⏩ 3 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Memory mem client unique-location-churn (react) 80 KB 75.5 KB +5.89%
👁 Memory mem client unique-location-churn (solid) 68.3 KB 71.2 KB -3.97%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ethan-huo:fix/visible-hydration-hook-order (47b3841) with main (ad5b3b7)

Open in CodSpeed

Footnotes

  1. 3 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. ↩

@Sheraff
Sheraff merged commit d66a84b into TanStack:main Nov 3, 2026
26 checks passed
@github-actions github-actions Bot mentioned this pull request Nov 3, 2026
Sheraff added a commit that referenced this pull request Nov 3, 2026
#8817's side for the split-out PRs:
- #8861 (Start router options): the PR's generator-config, Vite and
  Rsbuild wiring; main's start-router-wiring test and start-app helper
  dropped (start-build-options, start-dev-route-tree-footer and
  start-compose-project-path test the same four behaviours). Main's
  changeset kept; the duplicated bullets leave start-compiler-yuku.md.
- #8860 (split route source maps): the PR's own loader; compilers.ts stays
  deleted; main's source-maps test dropped (build-source-maps and
  dev-source-maps test webpack, Rspack and Vite dev). unplugin ^3.5.0 kept
  from main (no downgrade). Main's changeset kept; the PR's source map
  clause leaves router-plugin-yuku.md.
- #8653 (visible() hooks): merged to the PR's file, test and changeset
  unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

<Hydrate when={visible()}> throws React #467 when the gate resolves during a replayed mount

2 participants