Skip to content

fix(web): single React root and stable shell providers across navigation (CL-8505) - #899

Merged
TheGreatAxios merged 1 commit into
mainfrom
cl-8505-shell-crash
Sep 18, 2026
Merged

TheGreatAxios merged 1 commit into
mainfrom
cl-8505-shell-crash

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Two console failures, one mechanism each, both from how Vite's React Refresh
picks hot-update boundaries.

createRoot() called twice on the same container

main.tsx declared the Root component. A module that declares a component
is 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 call
warns (a container that has already been passed to createRoot()), and from
then on two reconcilers commit into one #root: each holds its own view of
the container's children, so a commit from one removes or moves a node the
other still tracks. That is the removeChild/insertBefore NotFoundError
pair, and the error-boundary recreate that follows it.

Fix: Root moves to src/root.tsx, leaving main.tsx with no component in
it, 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, so
even a re-execution reuses the one root it already created. No try/catch
around React anywhere.

useBench used outside BenchProvider

Same boundary rule, different consequence. bench-context.tsx called
createContext and exported BenchProvider, so it too self-accepts. On
a hot update Vite re-executes it — minting a brand-new BenchContext
object — but does not re-execute its importers, which keep their binding to
the previous module's useBench. The mounted provider then publishes the new
context while ShellChromeProvider reads the old one, finds no provider
above it, and throws. It surfaces during rapid navigation because that is
when a subtree remounts and re-reads context; the DOM NotFoundErrors that
follow come from the two roots above.

Fix: every createContext call moves to a component-free module, which is
never 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.tsBenchContext, BenchState
  • navigation-context.tsNavigateContext, SignOutContext, SessionUserContext
  • shell/canvas-host-context.tsCanvasHostContext and its types
  • shell/composer-insertion-context.tsComposerInsertionContext

Not changed

The artifacts Upload path was checked as a suspect and is clean: the file
input is an ordinary sr-only child of the page (no portal, no
body.appendChild), the off-route Upload flag is consumed once, and
openPicker only 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 adjustments
and Redirect/router-store already defer every navigation to a microtask
and were left alone.

Verification

bunx tsc -p apps/web --noEmit, bun run lint, bun run fmt,
apps/web bun run build, and bun test ./src (582 pass, 0 fail) are all
green. 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-boundary
mechanism 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.

…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.
@linear-code

linear-code Bot commented Sep 18, 2026

Copy link
Copy Markdown

CL-8505

@TheGreatAxios
TheGreatAxios merged commit 4f00360 into main Sep 18, 2026
4 checks passed
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