Widget host deps
Why widget bundles resolve `react` and `@todayai-labs/tck` to the app's own module instances, what the ESM island cost before that, and which options were rejected.
Widget bundles are built separately from apps/web and loaded at runtime. They
externalise a fixed set of bare specifiers, and the document's import map
decides what those resolve to. Getting that resolution wrong does not fail the
build, the typecheck, or any component test — it fails when a browser mounts one
widget, in one browser, in production.
This page records the constraint, the architecture it used to force, and the decision that removed it.
The constraint is stricter than "share React"
The TCK ABI's runtime instance semantics
require every specifier in TCK_EXTERNAL_SPECIFIERS to resolve to a single
physical URL, so host and widget share one module instance:
react
react-dom
react-dom/client
react/jsx-runtime
react/jsx-dev-runtime
scheduler
@todayai-labs/tck
@todayai-labs/tck/{manifest,patch,agent,hooks,runtime}The browser keys module entries by resolved URL, so every widget importing one of
these gets the same instance — React's fiber dispatcher, the scheduler queue, and
WidgetCtxContext all live in that one instance. The host's
<WidgetCtxContext.Provider> reaches a widget's useContext because both sides
resolve the context object to the same identity.
Two consequences that are easy to miss:
- It is not only React.
@todayai-labs/tckcarriesWidgetCtxContext, so a host that resolves the runtime package through its own bundler cannot reach widgets with a Provider even if React happens to match. - The code that renders a widget must itself resolve these specifiers to the same URLs. This is the expensive half of the contract, and the reason the architecture below existed.
The failure mode is instance identity, not version equality — two copies of the same React version are still two copies:
react-dom (instance A) --renders--> widget component
|
+-- useState from react (instance B)
-> reads B's internals
-> dispatcher is null -> "Invalid hook call"What it used to cost: the ESM island
Next's React lives inside bundler chunks with no stable public URL, so it cannot be named in an import map. The original answer inverted the problem: publish a second React under the ABI prefix and scope the map so Next's own chunks never touch it.
{
"imports": {},
"scopes": {
"/__tck/v1/": { "react": "/__tck/v1/react-runtime.mjs" },
"/api/widgets/v1/": { "react": "/__tck/v1/react-runtime.mjs" }
}
}imports is empty on purpose — the top level is untouched, so app chunks keep the
bundler's React. Only modules served under those prefixes resolve to the prebuilt
copy, and widget bundles are served from /api/widgets/v1/, so they get copy #2.
Because the renderer has to be on copy #2 too, the whole widget-rendering subtree
had to move there — a separately-bundled ESM island (canvas-boot/boot*.ts) with
its own createRoot. Two React trees in one document, sharing only DOM:
document
+-- app React root (instance A, Next bundler)
| +-- <div id="canvas-feed-root" data-*> <- attributes are the only channel down
+-- island React root (instance B, esbuild)
+-- widget (instance B, dynamic import)The two trees share no components, which is why the channels are a
MutationObserver on mount-node attributes going down and a bubbling
CustomEvent going up. Those are not workarounds; across two React instances
there is nothing else available.
The costs compounded:
- Every piece of island chrome is written twice. The island is bundled by
esbuild, and only specifiers in the import map stay external — everything else
is inlined.
@todayai-labs/opal-uiis Tailwind-class driven and the standalone/embeddocument carries no Tailwind sheet, so island chrome has to be transcribed into inline CSS. Card shell, status card, placeholder, control pill, progressive blur, icon paths, copy lookup — each exists twice. - Duplicates drift silently. The card shell is the live example:
@todayai-labs/tck-host/feed-card-shell(the promoted SDK recipe) uses a19.5pxradius with the 1px stroke as an inset shadow, whilecanvas-boot/feed-card-shell.tsuses24pxwith a realborderthat shrinks the content box by 1px a side. - Users download React twice on app surfaces: the bundler's copy plus
react-runtime.mjs.
The decision: the URL hands back the app's copy
An import map requires same URL → same module instance. It does not require the module behind that URL to be a prebuilt copy. So rather than moving the host onto the import map's copy, the URL returns the host's copy:
before: host joins the import map's copy (needs bundler cooperation -> blocked)
after: the import map's URL hands back the host's copyThree pieces, single-sourced through src/lib/tck-host-dep-shims.mjs:
| Piece | Role |
|---|---|
scripts/utils/build-host-dep-shims.mts | generates one shim module per group, re-exporting from a global |
src/lib/tck-host-deps-registry.ts | publishes the app's instances on that global; imported by the root client providers |
src/lib/tck-host-head.ts | points the app document's import map at the shims |
This is Module Federation's shared-scope mechanism expressed as an import map plus
generated re-exports. It is deliberately not a bundler feature, so it behaves
identically under Turbopack (dev) and webpack (prod) — which is what disqualified
the externals approach below.
Details that are load-bearing
- Generated, never hand-written. ESM named exports are static, so the export list has to be enumerated at build time. A hand-maintained list would be exactly the drift surface the ABI's single-source whitelist exists to prevent.
- The collision policy is copied, not invented. A group maps N specifiers onto
one file, and
reactandreact-domboth exportversion.tck-shared-depsresolves that first-wins in group order and takes the first spec'sdefault; the generator reproduces both rules, and also its ESM-native-vs-CJS-wrapper branch (an ESM-native group bundlesspecs[0]alone, so collecting from subpaths too would give the shim a wider surface than the bundle it replaces). - The parity test builds both sides.
build-host-dep-shims.test.mtsgenerates the shims and runstck-shared-deps' esbuild wrapper over the same installed packages, then compares export surfaces. It deliberately does not readpublic/__tck/v1/: that directory is gitignored and only written bydev/build, so in CI it is absent and a skip-on-absence test always passes. - Two maps, one per document — enforced by route structure, not the browser.
The shims read a global only the app tree populates, so a document that never runs
app code must resolve to the prebuilt copies.
TCK_HOST_IMPORT_MAP(shimmed) serves hydratingapp/routes;TCK_HOST_IMPORT_MAP_STANDALONE(prebuilt) serves/embed. What keeps them apart is that/embedis aroute.tsreturning raw HTML and so never inherits the root layout. Chromium 133+ accepts multiple import maps and merges them, earlier entries winning — so this is not a browser guarantee, and the/embed-as-a-Next-page item below has to account for it. - Both maps must cover the whole externals whitelist. A specifier a map omits
fails to resolve, before any export check. The standalone map originally listed
8 of the 14 entries — missing
react/jsx-dev-runtimeand all five@todayai-labs/tck/*subpaths, which is whereuseWidgetCtxlives — so a widget on/embedimporting it died at import.tck-host-head.test.tsnow asserts both maps againstTCK_EXTERNAL_SPECIFIERS, and that they share one key set. @todayai-labs/tck-hoststays prebuilt. It is host-only and absent from the ABI's widget whitelist, so no widget's identity depends on it.
The tradeoff this introduces
The prebuilt copy is version-anchored: the ABI label is encoded in the URL
path, so a widget generated against v1 keeps resolving to the v1 React
forever. A shim replaces that with "whatever React the app currently ships".
That is a real weakening of the contract, and it points the opposite way from the platform's "a widget freezes at generation time" semantics. It is accepted here because app surfaces already had no such guarantee in practice — they shipped both copies — but a future ABI bump has to decide this deliberately rather than inherit it.
Options that were rejected
Shrink the island to just the widget mount. Keeps chrome in the Next tree
where opal-ui works. Does not help /embed, which has no app tree, so the same
chrome would exist in two active implementations instead of one — the drift
surface moves rather than shrinks. Worth doing as cleanup after the island is
gone, not as the fix.
Module Federation. Rejected on both motive and mechanics; the SDK's own
positioning note
argues this is a widget platform, not a micro-frontend system, and walks through
the organisational motives that do not apply. Mechanically: widget bundles are not
built by webpack/rspack so they cannot join a build-time shared negotiation;
widgets are runtime-arbitrary and content-addressed, so there is no known remote
list; and dev/prod use different bundlers, so both would have to work. Its one
genuinely useful mechanism — shared scope — is what the shim reproduces without
adopting the framework.
Make the bundler emit external imports. Structurally blocked, three ways:
Next's client chunks are classic scripts plus a bundler runtime, so import x from "/url" cannot survive; output.module is unsupported; and dev runs Turbopack,
which has no externals equivalent, so dev and prod would diverge.
Integrity, accurately
tck-shared-deps does compute real sha384 digests per group and writes them into
manifest.json as the WICG { imports, integrity } shape. None of it is
enforced in this repo: getHostHeadNodes takes integrity as optional, neither
call site passes it, and manifest.json is never read at runtime — its only
consumer is the build script, which reads imports to derive esbuild externals.
So this change neither loses nor keeps SRI. Worth stating because the shim makes it
tempting to "restore" integrity here, and hashing react-runtime.shim.mjs would
attest a ~60-line re-export stub while the bytes that actually are React arrive
through bundler chunks that carry no digest — a control that reads as protection and
covers nothing. These are also same-origin assets, so an attacker who can rewrite
them can rewrite the document declaring the expected hash. If integrity on React
matters, the lever is Next's experimental.sri, not the shim.
Open items
- Deleting the island for
/,/feedsand/feed/[batchId]is the payoff and has not happened yet. It requires porting batch loading, cache, auth refresh, failure store, reveal planning, auto-height measurement, theme, lifecycle events, analytics, asset preload and the native bridge into the Next tree. - Whether
/embedshould become a Next page is genuinely open, and the original reasoning for it being a rawResponseno longer holds — the CSR-forcingnext/dynamic({ ssr: false })calls inProvidersare dev/preview only, and the root layout's import map lands fine on a normal Next page. But the embed document today is a<style>, one module script and the import map, so making it a Next page pulls in global CSS, fonts and the wholeProvidersruntime. That may well be a worse payload than the second React it saves, in which case/embedshould stay a minimal standalone document and share chrome through framework-free SDK recipes instead. Needs measurement. - Adopting
tck-host/feed-card-shellover the drifted local clone is an independent visual bug fix (24px vs 19.5px radius, real border vs inset stroke).
Chat runtime
Cross-package boundary overview for the chat-v2 runtime in `apps/web`. Implementation details live next to the code under `apps/web/docs/chat/`.
MSW mocks
MSW (Mock Service Worker) is the architectural mock layer — Storybook stories, unit tests, and the runtime `mockMode=true` path all share the same handlers and fixtures.