Skip to content

js_parser: include the hook binding in the React Fast Refresh signature - #43750

Merged
Jarred-Sumner merged 4 commits into
mainfrom
robobun/de143857/fast-refresh-hook-binding-signature
Oct 8, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
robobun/de143857/fast-refresh-hook-binding-signature

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With React Fast Refresh on, const [a, setA] = useState(0) and const [b, setB] = useState(0) both print _s(C, "9DqC+93o+Nw="). React keeps the state across the rename. react-refresh/babel resets it.
  • visit_decls (src/js_parser/visit/mod.rs:369) hashes the binding only when react_refresh.last_hook_seen is the initializer call. Nothing sets that field.
  • B::write_to_hasher (src/ast/b.rs:139) writes the length of an identifier name, not the name.

Fix

  • handle_react_refresh_hook_call records the call in last_hook_seen, after the hook context exists, so the unwraps in visit_decls hold. B::write_to_hasher writes the name bytes.
  • Data::write_to_hasher recursed with no bound: useState(x+x+…) with 100,000 terms is a segmentation fault on bun 1.4.3, and a binding default now reaches it. It now walks a binary chain on the heap, like the visitor and the printer. A StackCheck guards the other recursion.
  • Verified: test/bake/dev/react-spa.test.ts. Both new tests fail on bun 1.4.3. Also 25716.test.ts and bundler_jsx.test.ts.
  • Self-reviewed: 6 concerns raised, 3 addressed, 3 rejected (see Notes).

Background

  • The transform wraps each function that calls hooks: _s(Component, "<signature>"). If an edit changes the signature, React remounts the component and its state resets.
  • Bun feeds a per-function Wyhash in the visit pass: the name of each /^use[A-Z]/ call, and the initial state argument of useState and useReducer.
  • B is the AST node of a binding pattern: identifier, array or object.

Downsides

  • A rename or reshape of a destructured hook result now resets the state of that component, as with Babel. const { data } = useQuery() to const { data, error } = useQuery() remounts.
  • Under a TypeScript wrapper (useState(0) as T) Bun hashes the binding and Babel does not. A rename there resets in Bun only.
Notes

Upstream. react-refresh/babel builds the key as useState{[a, setA](0)}: hook name, source text of the declarator id, initial state (ReactFreshBabelPlugin.js:422-436). React tests it: "resets state when renaming a state variable" (ReactFreshIntegration-test.js:593). The Zig sources had the same two gaps since the transform landed in #13215.

No remount on upgrade. Signatures are not stored. A hook call that is not a whole initializer keeps its signature (useState(0); stays 9DqC+93o+Nw=).

Which declarations take the binding. Only a hook call that is the whole initializer: const x = useA(), const [a, b] = useA(), const { a: b } = useA(), each declarator of const a = useA(), b = useB(), and the outer call of const x = useA(useB()). const x = [useA()], const x = foo(useA()), const x = await useA() and x = useA() do not, as in Babel (path.parent.type === 'VariableDeclarator', ReactFreshBabelPlugin.js:423).

Why the unwraps hold. visit_decls sets last_hook_seen to None before it visits each initializer. handle_react_refresh_hook_call sets it only after it created the HookContext of the current function. A hook call outside a function returns before that. The value is compared by address only. It is never read through. Equal addresses mean the call was handled during the visit of this initializer, with no function boundary between, so the hook context is the same one.

Hash bytes. Per hook call: name, initial state argument, \0, then the binding. An identifier binding is tag, length, name. The first byte after \0 is a binding tag (0 to 2) or the u of the next hook name, so the stream is not ambiguous.

Checks.

  • Suites on the debug (ASAN) build: test/bake/dev/react-spa.test.ts (8 pass), test/bake/dev/hot.test.ts (11 pass), test/regression/issue/25716.test.ts (2 pass), test/bundler/bundler_jsx.test.ts (57 pass, 5 todo).
  • 78 .tsx/.jsx fixtures from test/ through bun build --react-fast-refresh --external '*' with bun 1.4.3 and with this branch (debug, ASAN). Exit codes are equal. The outputs are identical after the signature strings are masked. 50 files have a new signature. The 28 files with only unassigned hook calls keep theirs.
  • A differential run against react-refresh/babel over the 42 render/patch pairs of upstream ReactFreshIntegration-test.js (69 function/pair rows). Rows that disagree with Babel: 21 on main, 15 on this branch. No row regresses. The 6 fixed rows are the "resets state when renaming a state variable" tests (plain, in a HOC, with indirection, direct call), "does not get confused when custom hooks are reordered" and "does not get confused when component is called early".
  • Signatures are equal with and without --minify, --minify-syntax, --minify-identifiers and --react-compiler.
  • Deep input (debug, ASAN): a 100,000 term chain as useState argument, useReducer argument, binding default and computed key builds, and the whole chain is hashed. Two chains that differ only in the first term get different signatures. With the stack check alone they got the same one, because the hash stopped part way down. A 100,000 deep x.a.a.a… argument gives error: Maximum call stack size exceeded from the visit pass. bun 1.4.3 gives a segmentation fault for the argument cases. Nested binding patterns stop at the parser limit with the same error, below the depth where the hash could overflow.
  • The heap walk writes the same bytes as the recursion did. A function with nine unassigned hook calls over mixed binary expressions (left deep, right deep, ??, comma, assignment, **, unary) gives OUTRQVtcIug= on bun 1.4.3 and on this branch.

Self-review, rejected concerns.

  • TypeScript wrappers (as, !, satisfies): the parser drops the wrapper, so Bun hashes the binding where Babel does not. A rename is still a rename. A reset is the safe direction.
  • for (let i = useA(); ; ): the binding is not hashed, because visit_for_loop_init does not go through visit_decls. A hook in a loop header breaks the rules of hooks.
  • An initializer that folds to a hook call (null ?? useA(), false ? useB() : useA()) takes the binding only when the surviving call was visited last. These are conditional hooks.

Not changed here (the same on main).

  • The initial state is hashed by tag only for objects, arrows, calls, templates, new, JSX and conditionals. useState({ count: 0 }) to useState({ count: 1 }) keeps the state.
  • // @refresh reset has no effect (force_reset is never set).
  • .ts files get no signatures, so a custom hook in a .ts file is not tracked.
  • useA?.() counts as a hook call in Bun, not in Babel.

Bugs found outside this change. No open PR or issue covers them.

  • bun build --react-fast-refresh on export default function App() { const [a] = useState(0); return a; } prints const App = _s(function App() { … }, "…"); var App = App;. The output does not parse: SyntaxError: Identifier 'App' has already been declared. The dev server path is not affected.
  • A class method that calls a hook (class K { m() { useState(1) } }) panics in lower_class (src/js_parser/p.rs:7399, internal error: entered unreachable code) for .ts and .tsx. For .jsx it prints m: _s(function() { … }, "…") inside the class body, which does not parse.

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/react-spa.test.ts
bun test v1.4.3 (367d939d9)

test/bake/dev/react-spa.test.ts:
Dev server testing directory: /tmp/bun-dev-test-mYwZnq
bun install v1.4.3 (367d939d9)

+ react@19.2.4
+ react-dom@19.2.4

3 packages installed [120.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:41787
�[0;30mdev|�[0m �[32mBundled page in 1992ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 53ms�[0m�[2m:�[0m App.tsx
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
(pass)  DEV:react-spa-1: react in html [6086.87ms]
�[0;30mdev|�[0m Started development server: http://localhost:44675
�[0;30mdev|�[0m �[32mBundled page in 90ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 46ms�[0m�[2m:�[0m App.tsx
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 51ms�[0m�[2m:�[0m App.tsx
(pass)  DEV:react-spa-2: react
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/bake/dev/react-spa.test.ts:
Dev server testing directory: /tmp/bun-dev-test-DwNaaK
bun install v1.4.3-canary.1 (367d939d9)

+ react@19.2.4
+ react-dom@19.2.4

3 packages installed [3.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:41197
�[0;30mdev|�[0m �[32mBundled page in 51ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 2ms�[0m�[2m:�[0m App.tsx
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
(pass)  DEV:react-spa-1: react in html [1225.24ms]
�[0;30mdev|�[0m Started development server: http://localhost:38837
�[0;30mdev|�[0m �[32mBundled page in 3ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m App.tsx
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m App.tsx
(pass)  DEV:react-spa-2: react refresh should register and track hook state [691.80ms]
�[0;30mdev|�[0m Started development server: http://localhost:35519
�[0;30mdev|�[0m �[32mBundled p
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/react-spa.test.ts
bun test v1.4.3 (367d939d9)

test/bake/dev/react-spa.test.ts:
Dev server testing directory: /tmp/bun-dev-test-Kx2Mrh
bun install v1.4.3 (367d939d9)

+ react@19.2.4
+ react-dom@19.2.4

3 packages installed [87.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:44623
�[0;30mdev|�[0m �[32mBundled page in 1288ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 34ms�[0m�[2m:�[0m App.tsx
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
(pass)  DEV:react-spa-1: react in html [4819.43ms]
�[0;30mdev|�[0m Started development server: http://localhost:33215
�[0;30mdev|�[0m �[32mBundled page in 73ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[32mReloaded in 31ms�[0m�[2m:�[0m App.tsx
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 33ms�[0m�[2m:�[0m App.tsx
(pass)  DEV:react-spa-2: react 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9a553d5aed
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 932ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
244 units: 172 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1499] mkdir stamps
[2/1499] mkdir codegen
[3/1499] rustc unicode_ident 
[4/1499] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 26 installs across 65 packages (no changes) [104.00ms]
[5/1499] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[6/1499] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [1.00ms]
[7/1499] rustc unicode_xid 
[8/1499] install /workspace/bun/src/node-fallbacks
bun instal
... (truncated)
diff hotspot
src/ast/b.rs                    |   1 +
 src/ast/expr.rs                 |  78 +++++++++++++++++----
 src/js_parser/p.rs              |   2 +
 test/bake/dev/react-spa.test.ts | 150 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 219 insertions(+), 12 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                             reads  edits  tests
src/ast/b.rs                         2      1     25
src/ast/expr.rs                      4      2     25
src/js_parser/p.rs                   2      2     25
test/bake/dev/react-spa.test.ts      3      1     25

The visit pass has a block that hashes the binding of a declaration whose
initializer is a hook call. It compares the call with
`react_refresh.last_hook_seen`, and nothing set that field, so the block
never ran. `handle_react_refresh_hook_call` now records the call.

`B::write_to_hasher` wrote the tag and the length of an identifier name,
and not the name. It now writes the name too, so `[a, setA]` and
`[b, setB]` get different signatures.
…he stack runs out

`Data::write_to_hasher` recursed with no bound. The parser builds
`a + b + ...` and `a.b.c...` in a loop, so a long chain in the initial
state of `useState` overflowed the stack. With the binding in the
signature, a default value or a computed key in the binding reaches the
same recursion. It now takes a `StackCheck`, like `known_primitive`, and
stops when it is not safe to recurse.

The binding test also covers a hook call inside another call, and a hook
call that is an argument of another hook call.
@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: CI is green (Buildkite build 119524) and the automated reviews found no open issue. The PR is ready for a maintainer.

How I reproduced it on bun 1.4.3:

mkdir /tmp/rfr && cd /tmp/rfr
echo 'import { useState } from "react"; export function C() { const [a, setA] = useState(0); return a; }' > a.jsx
echo 'import { useState } from "react"; export function C() { const [b, setB] = useState(0); return b; }' > b.jsx
bun build --react-fast-refresh --target=browser --external '*' a.jsx | grep '_s(C'
bun build --react-fast-refresh --target=browser --external '*' b.jsx | grep '_s(C'

Both commands print _s(C, "9DqC+93o+Nw=");. With this branch they print MYOWkCCOP/k= and k1gPmYczwos=. A bare useState(0); keeps 9DqC+93o+Nw=.

The stack overflow: put useState(x+x+…+x) with 100,000 terms in a component and run the same command. bun 1.4.3 exits with a segmentation fault. This branch builds the file and hashes the whole chain.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 6edd2352-4381-4062-bfb4-97a57149dd7b

📥 Commits

Reviewing files that changed from the base of the PR and between 01f5cc6 and 9a553d5.

📒 Files selected for processing (2)
  • src/ast/expr.rs
  • test/bake/dev/react-spa.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

Changes

Hashing and React Refresh

Layer / File(s) Summary
Stack-safe AST hashing
src/ast/b.rs, src/ast/expr.rs, test/bake/dev/react-spa.test.ts
Identifier hashing now includes original-name bytes. Recursive expression hashing stops when the stack is unsafe. Binary hashing iteratively walks left-nested chains. A deep operator-chain regression test covers stack overflow behavior.
React Refresh hook tracking and signatures
src/js_parser/p.rs, test/bake/dev/react-spa.test.ts
React Refresh records the latest processed hook call. Tests cover signature behavior for binding names, shapes, defaults, aliases, initializers, and nested calls.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 9a553

This change improves React Fast Refresh signature accuracy and prevents stack failures while hashing deeply nested expressions. No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: including hook bindings in React Fast Refresh signatures.
Description check ✅ Passed The description explains the problem, implementation, scope, downsides, and verification results. It does not use the exact template headings, but it provides the required information in equivalent se…

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

The stack check made the hash stop part way down a chain like
`x + x + ...` with 100,000 terms, so an edit to the first terms did not
change the signature. `Data::write_to_hasher` now walks the left spine
of a binary expression on the heap, as the visitor and the printer do. The
bytes that it writes are the same as before. The stack check stays for
`a.b.c...`, which the visit pass reports as too deep.

The test compares two chains that differ in the first term.
Comment thread src/ast/expr.rs Outdated
Comment thread src/ast/expr.rs Outdated
@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:12 PM PT - Sep 21st, 2026

✅ @robobun, your commit 9a553d5aedb9cbfe4c7214bb016260e16b039824 passed in Build #119524! 🎉


🧪   To try this PR locally:

bunx bun-pr 43750

That installs a local version of the PR into your bun-43750 executable, so you can run:

bun-43750 --bun

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the merge risk note from the automated review (a very deep initializer can keep the component state after an edit): the note was correct for the first version of the stack check.

With the stack check alone, the hash stopped part way down a long chain. Two useState(x+x+…) initializers with 100,000 terms that differ only in the first term got the same signature (LLtCcQgzf/k= on the debug build).

d3e78a4 changes Data::write_to_hasher to walk the left spine of a binary expression on the heap, as the visitor and the printer do, so the whole chain is hashed. The bytes are the same as with the recursion, so no other signature changes. The test hook signature of an operator chain deeper than the stack now compares the two chains and fails on the version with the stack check alone.

The stack check stays for member chains (a.b.c…). The visit pass reports a chain that deep as Maximum call stack size exceeded, so the build fails and no signature ships for it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit 5749c31 into main Oct 8, 2026
11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/de143857/fast-refresh-hook-binding-signature branch October 8, 2026 00:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants