Today Platform Web — Dev Docs
Architecture

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:

  1. It is not only React. @todayai-labs/tck carries WidgetCtxContext, so a host that resolves the runtime package through its own bundler cannot reach widgets with a Provider even if React happens to match.
  2. 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-ui is Tailwind-class driven and the standalone /embed document 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 a 19.5px radius with the 1px stroke as an inset shadow, while canvas-boot/feed-card-shell.ts uses 24px with a real border that 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 copy

Three pieces, single-sourced through src/lib/tck-host-dep-shims.mjs:

PieceRole
scripts/utils/build-host-dep-shims.mtsgenerates one shim module per group, re-exporting from a global
src/lib/tck-host-deps-registry.tspublishes the app's instances on that global; imported by the root client providers
src/lib/tck-host-head.tspoints 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 react and react-dom both export version. tck-shared-deps resolves that first-wins in group order and takes the first spec's default; the generator reproduces both rules, and also its ESM-native-vs-CJS-wrapper branch (an ESM-native group bundles specs[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.mts generates the shims and runs tck-shared-deps' esbuild wrapper over the same installed packages, then compares export surfaces. It deliberately does not read public/__tck/v1/: that directory is gitignored and only written by dev/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 hydrating app/ routes and TCK_HOST_IMPORT_MAP_STANDALONE (prebuilt) for /embed, kept apart only by /embed being a route.ts that 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-runtime and all five @todayai-labs/tck/* subpaths, which is where useWidgetCtx lives — so a widget on /embed importing it died at import. tck-host-head.test.ts asserts the surviving map against TCK_EXTERNAL_SPECIFIERS in both directions: full coverage, and no extras.
  • Nothing is prebuilt any more. @todayai-labs/tck-host and its /mount subpath were the last two map entries pointing at prebuilt groups, on the grounds that tck-host is 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 taking mountHost as an external. Removing them let buildPlatform go entirely, so public/__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.json recording them. A shim has no anchor by construction — it re-exports whatever globalThis.__TCK_HOST_DEPS__ holds, which is whatever apps/web resolved React and @todayai-labs/tck to at build time. Today that is a feature: host and widget cannot disagree because there is only one copy. It becomes a question the moment v2 exists alongside v1, 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.

On this page