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 and the webpack fallback — 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. - One map, because every document now carries the app tree. There were two:
TCK_HOST_IMPORT_MAP(shimmed) for hydratingapp/routes andTCK_HOST_IMPORT_MAP_STANDALONE(prebuilt) for/embed, kept apart only by/embedbeing aroute.tsthat returned raw HTML and so never inherited the root layout. That separation was never a browser guarantee — Chromium 133+ accepts multiple import maps and merges them, earliest entry winning — so two maps in one document would have silently merged rather than failed./embed/*is a Next page now, so the standalone map is deleted instead of left as a footgun that only misfires when someone adds a third surface. - The map 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.tsasserts the surviving map againstTCK_EXTERNAL_SPECIFIERSin both directions: full coverage, and no extras. - Nothing is prebuilt any more.
@todayai-labs/tck-hostand its/mountsubpath were the last two map entries pointing at prebuilt groups, on the grounds thattck-hostis host-only and absent from the widget whitelist. That is still true — which is why the entries had no reachable consumer once the island stopped takingmountHostas an external. Removing them letbuildPlatformgo entirely, sopublic/__tck/v1/is now the two shims plus the host stylesheet.
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. Rejected at the time because it did not help /embed, which had no
app tree, so the same chrome would exist in two active implementations instead of
one — the drift surface moves rather than shrinks.
Worth recording how this resolved, because the reasoning inverted: once /embed
became a Next page the island could go to zero rather than shrink. A card's
mount boundary needs bare-specifier resolution (import map plus shims, both static
assets), one mountHost per document, and a runtime import() of a hash-addressed
URL — none of which needs a bundle boundary. opal-ui's
live-widgets/tck-bundle-widget was already doing exactly that inside the app tree
with no island at all.
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.
Settled items
The three open items this page carried are all closed. Recorded rather than deleted, because two of them were closed in the opposite direction to the one proposed here.
The island is deleted. /, /feeds, /feed/[batchId] and /embed/* all render
features/today-feed/ from the Next tree. Everything the list named was ported:
batch loading and cache onto the app's React Query client and IDB persister, auth
refresh, failure store, reveal planning, auto-height measurement, theme, native
lifecycle events, analytics, asset preload and the WebView bridge.
Two things that were not ports are worth naming, because both were regressions the
import graph could not show. <FeedCanvasMount> had zero importers and was still the
only caller of enterLiveWidgetEditMode, and the only renderer of
<LiveWidgetGuidedCreation> / <LiveWidgetDeleteController> — deleting it took edit
mode, the section's "+" and the delete confirmation with it, all reached through
bubbling intents and stores rather than imports. And the island's edit chrome was the
only thing injecting the wiggle keyframes, so the frame kept setting the marker while
computed animation-name was none. When deleting a module here, audit its
side-effect surface — listeners, store writes, document.head appends — not its
importers.
/embed/* became a Next page, and the payload trade this page described no longer
exists in that form. It weighed "global CSS, fonts and Providers" against "the second
React it saves" — but with the island gone there is no second React to weigh. The four
prebuilt groups (≈936 KB, of which 590 KB was React) are no longer built at all, and
the shims serve every whitelisted specifier from the app's own instances. The
standalone-document alternative is also no longer cheap: keeping it would mean reviving
prebuilt host deps and a second import map purely to serve one route.
feed-card-shell was unified, in the reverse direction. This page proposed
adopting tck-host/feed-card-shell over the local clone as a bug fix, on the reading
that 19.5px and an inset stroke were canonical. They were not: 19.5px is 1.5rem
resolved against the preview playground's 13px root, frozen into the SDK by accident,
and the local copy additionally had contain: layout style paint (a real
resize-performance fix) and a fill-less variant the SDK lacked. So the web values
were promoted into @todayai-labs/opal-ui/feed-card-shell and apps/web no longer
references the tck-host recipe. The same shape repeated with the edit-mode wiggle:
the island's fork had 10 phase buckets against opal-ui's 8, and 10 was correct, so
opal-ui moved.
Still open
- Shim version-anchoring must be decided explicitly at the next ABI bump. The
prebuilt groups were anchored: built from specific installed versions, with a
manifest.jsonrecording them. A shim has no anchor by construction — it re-exports whateverglobalThis.__TCK_HOST_DEPS__holds, which is whateverapps/webresolved React and@todayai-labs/tckto at build time. Today that is a feature: host and widget cannot disagree because there is only one copy. It becomes a question the momentv2exists alongsidev1, because two ABI versions served from one app tree would both read the same global and get the same instances regardless of which version they asked for. Decide then whether the registry key carries the ABI version, or whether one app tree may only serve one ABI at a time.