fix(web): single React root and stable shell providers across navigation (CL-8505) - #899
Merged
Merged
Conversation
…ion (CL-8505) The entry module declared the Root component, which makes it a React Refresh boundary: a hot update re-executed it in place and called createRoot() on #root a second time, leaving two reconcilers committing into one container. Root moves to its own module so the entry declares no component, and the root is kept on the HMR data slot. Each createContext() call also shared a module with a component, so a hot update minted a fresh context object for the provider while importers that were not re-executed kept reading the previous one — the shape behind 'useBench used outside BenchProvider'. The context objects move to component-free modules.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two console failures, one mechanism each, both from how Vite's React Refresh
picks hot-update boundaries.
createRoot()called twice on the same containermain.tsxdeclared theRootcomponent. A module that declares a componentis turned into a self-accepting React Refresh boundary, so a hot update
re-executes its whole module body in place instead of reloading the page —
including the top-level
createRoot(container).render(...). The second callwarns (
a container that has already been passed to createRoot()), and fromthen on two reconcilers commit into one
#root: each holds its own view ofthe container's children, so a commit from one removes or moves a node the
other still tracks. That is the
removeChild/insertBeforeNotFoundErrorpair, and the error-boundary recreate that follows it.
Fix:
Rootmoves tosrc/root.tsx, leavingmain.tsxwith no component init, so an update to the entry triggers a real page reload rather than a
re-execution. The root is additionally held on
import.meta.hot.data, soeven a re-execution reuses the one root it already created. No
try/catcharound React anywhere.
useBench used outside BenchProviderSame boundary rule, different consequence.
bench-context.tsxcalledcreateContextand exportedBenchProvider, so it too self-accepts. Ona hot update Vite re-executes it — minting a brand-new
BenchContextobject — but does not re-execute its importers, which keep their binding to
the previous module's
useBench. The mounted provider then publishes the newcontext while
ShellChromeProviderreads the old one, finds no providerabove it, and throws. It surfaces during rapid navigation because that is
when a subtree remounts and re-reads context; the DOM
NotFoundErrors thatfollow come from the two roots above.
Fix: every
createContextcall moves to a component-free module, which isnever a refresh boundary — a change to it propagates to the importing
boundaries, so provider and consumers always share one context identity.
Providers and hooks stay exactly where they were and re-export the moved
symbols, so no import site changes:
bench-context-value.ts—BenchContext,BenchStatenavigation-context.ts—NavigateContext,SignOutContext,SessionUserContextshell/canvas-host-context.ts—CanvasHostContextand its typesshell/composer-insertion-context.ts—ComposerInsertionContextNot changed
The artifacts Upload path was checked as a suspect and is clean: the file
input is an ordinary
sr-onlychild of the page (no portal, nobody.appendChild), the off-route Upload flag is consumed once, andopenPickeronly calls.click()on a ref that is mounted at that point.Nothing there unmounts DOM React does not own, so it needed no change once
the second root was gone.
ShellChromeProvider's render-phase adjustmentsand
Redirect/router-storealready defer every navigation to a microtaskand were left alone.
Verification
bunx tsc -p apps/web --noEmit,bun run lint,bun run fmt,apps/webbun run build, andbun test ./src(582 pass, 0 fail) are allgreen. The behavior itself is a dev-server HMR path: the local stack serves
main, not this branch, so the fix is argued from the refresh-boundarymechanism and the build, not from a live reproduction. No test is added —
neither cause is reachable from a unit-test harness, which has no HMR.