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 (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 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.
  • 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 hydrating app/ routes; TCK_HOST_IMPORT_MAP_STANDALONE (prebuilt) serves /embed. What keeps them apart is that /embed is a route.ts returning 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-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 now asserts both maps against TCK_EXTERNAL_SPECIFIERS, and that they share one key set.
  • @todayai-labs/tck-host stays 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 /, /feeds and /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 /embed should become a Next page is genuinely open, and the original reasoning for it being a raw Response no longer holds — the CSR-forcing next/dynamic({ ssr: false }) calls in Providers are 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 whole Providers runtime. That may well be a worse payload than the second React it saves, in which case /embed should stay a minimal standalone document and share chrome through framework-free SDK recipes instead. Needs measurement.
  • Adopting tck-host/feed-card-shell over the drifted local clone is an independent visual bug fix (24px vs 19.5px radius, real border vs inset stroke).

On this page