fix(shell): align Vite import-map URLs with served browserHash#28
Conversation
Plugin UIs loaded into the shell (CRM, future modules) were rendering blank with `Cannot read properties of null (reading 'useState')` from `installHook.js`. Root cause: two copies of React in the same app. The shell builds an HTML import map at boot time so dynamically loaded plugin entry points resolve `react`, `react-dom`, etc. against the shell's own optimised dep bundles. The map URLs were templated with `meta.hash` from `node_modules/.vite/deps/_metadata.json`, but Vite's own `<script type="module">` injection and dep request handler use `meta.browserHash` (a separate field that updates whenever the optimised-deps surface changes, e.g. after installing a plugin). When the two diverged the shell loaded `react.js?v=<hash>` while a plugin loaded `react.js?v=<browserHash>` — two URLs, two module graphs, two `ReactCurrentDispatcher` slots, hooks broken. Fix is one-character per usage: read `browserHash` from the metadata file in `readMetadata()` and use it consistently in `depUrlFor()` and the index.html `transformIndexHtml.handler`. Manual repro that this fixes: open `/pipeline` (CRM Pipeline config) on a tenant with the CRM module installed — page now renders. Closes hebbs-ai#22.
parag
left a comment
There was a problem hiding this comment.
This one's the opposite shape from the last three. Tight, surgical, the diagnosis is right, and the comment explaining why is the kind of thing future-me will be glad someone wrote. Approving with one small suggestion. Let me walk through it the same way as the others so the reasoning is on the record.
What was actually broken
The shell ships a dynamic plugin loader: a .hebbsmod bundle (the CRM is the canonical one) gets uploaded, the framework extracts it, and the browser pulls a freshly-built JS bundle in via import() at runtime. That bundle wants react, react-dom, react-router-dom, etc. — but it can't ship its own copies, because if both the shell and the plugin bring their own React, you get two ReactCurrentDispatcher instances and hooks immediately blow up with "Invalid hook call / Cannot read 'useState' of null". One React instance per page, full stop.
The mechanism Vite gives you for this is import maps. The shell injects an <script type="importmap"> into the page at boot, mapping bare specifiers like "react" onto the actual file URL Vite is serving from .vite/deps/. When the plugin's bundle hits import { useState } from "react", the browser walks the import map, fetches the same URL the shell is fetching, and gets the same module instance.
That works only if the URLs match exactly. URL strings are the browser's module identity. react.js?v=abc and react.js?v=xyz are two different modules to the browser, even if they point at byte-identical files on disk. Same hostname, same path, different query — different module. Two Reacts.
What was actually wrong
Vite writes two fields into node_modules/.vite/deps/_metadata.json:
hash— the optimizer's internal stamp that says "are my optimised deps stale; do I need to re-bundle?" Changes when the resolved dep set changes.browserHash— the URL stamp Vite puts on every dep request it serves. When shell source code doesimport "react", Vite rewrites that to/node_modules/.vite/deps/react.js?v=<browserHash>. This is the one the browser actually sees.
These two fields are not the same value and aren't required to be. On the install I checked, hash: "0e0d773e" vs browserHash: "b9b85f06". They drift apart whenever the optimised-deps surface evolves without the resolved-dep-set changing (e.g. installing a plugin that re-triggers optimisation).
The shell's import-map builder was reading meta.hash and stamping the import map URLs with it. Vite's own request handler was stamping with meta.browserHash. Two different ?v= values on the wire for the same React file. Browser module-cached both. Two Reacts. Blank screen.
This is the kind of bug that only shows up the moment a runtime-loaded plugin tries to render — the shell itself works fine in isolation because all of the shell's React imports use browserHash consistently. You only see the divergence at the seam between shell-rewritten imports and import-map-resolved imports.
What the fix does
Three sites in vite.config.ts were stamping URLs with meta.hash. The diff changes all three to meta.browserHash:
depUrlFor()— builds the URL the shim file imports from.transformIndexHtml.handler— builds the/runtime-shims/<spec>.js?v=...entries in the import map.- Same handler — builds the direct
/node_modules/.vite/deps/<file>.js?v=...entries for ESM-passthrough deps.
I greped for meta.hash after applying — only comment occurrences remain. The fix is complete: every place a ?v= query lands in front of the browser now uses the same hash Vite itself uses.
Why this is the right shape
- Surgical. Three character-level changes plus a type widening. No new abstractions, no new files.
- Self-documenting. The
// CRITICAL: use browserHash, NOT hashcomment is the kind of thing that prevents this exact regression from creeping back in six months when someone "cleans up" the metadata reader. Specifically calling out the failure mode ("Cannot read 'useState' of null") means a future debugger searching the codebase for that string will land here first. - Catches everything. Because Vite uses one canonical
browserHashfor every dep URL it serves, and the import map now uses the same value, the dedup is total. Doesn't matter whether the plugin is React-router, Tanstack Query, or some future@boringos/ui-sibling — all roads point to one module per spec. - No cascading complexity. This doesn't need an integration test, doesn't need a registry, doesn't need a refactor. The right field exists in the metadata file; the bug was reading the wrong one.
One small thing worth fixing in the same commit
readMetadata's return type still includes the hash field (vite.config.ts:97-101), but after this fix nothing in the codebase reads it. The grep confirms that. Two ways to handle it:
- Drop it from the type and from the JSDoc — cleanest, and it stops anyone from grabbing the wrong field by autocomplete next time.
- Leave it but add a one-liner comment on the field:
hash: string; // do not use for URL stamping; see depUrlFor.
Either is fine. Dropping it is what I'd do — the comment block on depUrlFor already explains the distinction, and keeping a now-dead field in the type just creates the future regression.
One small thing to flag, not block on
The PR description says it "Pairs with #20 (CRM REST shim)" and that the blank-screen symptom had "two contributing causes; this one is the React duplication, the other is the missing v1 REST surface."
Per my comments on #27 — I don't think that second contributing cause exists. The CRM web bundle has zero raw /api/crm/* fetches in production code; the only references are comments describing removed code. The blank screen, in everything I can grep, comes from the React duplication that this PR fixes. #28 alone closes #22 and probably closes #20 too.
That's not a blocker on this PR — just a heads-up that the "pairs with #27" framing isn't accurate, and we should update issue #20's resolution narrative once this lands.
Nice things worth calling out
- Naming the failure symptom in the comment. "Cannot read properties of null (reading 'useState')" is the literal browser error. Search-friendly. Future debugger thanks you.
- Choosing to comment, not to abstract. A weaker version of this PR introduces a
BROWSER_HASH_FIELD = "browserHash"constant and a wrapper function, "to centralise the fix." That would have made the next person reading it work harder, not less. The right call was a comment that explains the distinction andmeta.browserHashinline. - No test added, no test wanted. The fix is "use the right field in
_metadata.json," which is a contract with Vite. The only meaningful integration test would boot the dev server, scrape the served HTML, and assert URL alignment — that's a lot of harness for a one-character class of fix. The manual test plan ("install CRM, open/pipeline, page renders, network tab shows aligned?v=") is exactly the right shape.
Verdict
Approve, with the one nit on the unused hash field in readMetadata's return type. Squash + merge after that. This is the actual fix for the blank CRM screens; #27 should close as superseded.
Summary
Plugin UIs loaded into the shell (CRM, future modules) rendered blank with
Cannot read properties of null (reading 'useState'). Two copies of React were ending up in the same app. Closes #22.Root cause
The shell builds an HTML import map at boot time so dynamically loaded plugin entry points resolve
react,react-dom, etc. against the shell's optimised dep bundles. The map URLs were templated withmeta.hashfromnode_modules/.vite/deps/_metadata.json, but Vite's own<script type=module>injection and dep request handler usemeta.browserHash— a separate field that updates whenever the optimised-deps surface changes (e.g. after a plugin install).When the two diverged the shell loaded
react.js?v=<hash>while a plugin loadedreact.js?v=<browserHash>— two URLs, two module graphs, twoReactCurrentDispatcherslots, hooks broken.Fix
Read
browserHashfrom the metadata file inreadMetadata()and use it consistently indepUrlFor()and thetransformIndexHtml.handler. Effectively one-character per usage.Test plan
/pipeline(CRM Pipeline configuration) — page now renders instead of blank./node_modules/.vite/deps/*.js?v=…request from both the shell and the plugin uses the same hash query.Invalid hook callerrors in the console.Related
Pairs with #20 (CRM REST shim) — the blank-screen symptom on
/pipelinehad two contributing causes; this one is the React duplication, the other is the missing v1 REST surface.Made with Cursor