Exporting frontend code

Status: design. The import path on this page is not built at f97fbe617. React components already cross the plugin boundary — but by value, at runtime: slot owner props hand components down (Original, §4.3), registration objects carry them (markdown extensions, composer typeahead sources), the @bb/ui/domain facades resolve them through renderer slots and ui-markdown/render (§4.11), and the UI plugins share whole browser-side service objects — components included — through a window-level table (step 1). What does not exist is sharing by import: no loader or builder path lets one plugin import another plugin's module. The builder rejects sibling /app imports, and the two workspace packages that expose an ./app entry serve tests only. This page states the by-value surface as it is, then gives the normative design for the import path — declaration, specifier, version rule, failure modes — against the real constraints of the import map, React identity, CSS scoping, and worker-isolated marketplace rows. Appendix B tracks it as H9.

Use this when

  • Ship a component kit with your plugin. A git plugin exports BranchBadge; dashboards render it without reimplementing.
  • Share a hook, not a service. A hook owns React state and subscriptions; a service handle cannot.
  • Split one product across plugins. A suite's plugins share one design system that is not @bb/ui's business.
  • Decide between a slot, a service, and an export. A slot keeps placement with the declarer; a browser-local service hands a component over at runtime; an export hands it over at build time, typed and synchronous. Step 1 draws the line.
  • Replace an app-services.ts copy. The window-table stopgap in the built UI plugins is what app.services.provide and, for build-time needs, this export surface retire.

What you build

The deploy plugin exports DeployBadge and useReleaseState from a new app-exports.ts entry. A second plugin, dashboard, imports @get-bb/plugin-deploy/app and renders the badge inside its own pane. The steps define the surface each side uses.

Steps

1. Know what already works: sharing by value

Three built channels move components across the boundary today, and none of them is an import:

  1. Slot machinery. Owner props carry components (Original, SlotComponentInput), and several slots take registration objects whose fields are components: markdown.extension registers { name, Component } rows, composer.typeahead.source and composer.submit are data slots whose values include render callbacks, and message.action occupants are components (§5).
  2. The kit facades. @bb/ui/domain's Markdown, Diff, and SourceCode are components every plugin may import from the map; they resolve at render time through ui-markdown/render and the diff.renderer/source.renderer slots, and render Unavailable when nothing provides them (§4.11).
  3. Browser-local app services. The built UI plugins publish browser-side service objects to their siblings — ui-markdown/render hands out { Markdown: ComponentType<MarkdownProps> }, ui-shell/dialogs hands out open(Component, props) — through one window-level table keyed Symbol.for("bb.ui-shell.services"). The SDK has no app.services.provide yet, so each plugin carries a verbatim copy of app-services.ts that reads and writes that table; the copies are deleted, not edited, when the SDK API lands. useService on the consumer side resolves these ids like any local service (§4.9).

The typed form of channel 3 is design (§4.16): the SDK adds an AppServices merge registry plus app.services.provide, so the provider declares the interface once in its contracts module and every consumer's useService("ui-markdown/render") is typed with no assertion. React types enter contracts type-only, which erases — contracts stay tier-neutral, and ui-timeline-view/contracts.ts already imports ComponentType this way today:

// ui-markdown/contracts.ts
import type { ComponentType } from "react";
export interface MarkdownRenderService { Markdown: ComponentType<MarkdownProps> }
declare module "@get-bb/plugin-sdk/app" {
  interface AppServices { "ui-markdown/render": MarkdownRenderService }
}

By-value sharing is the right tool when the provider owns the component's identity and the consumer can tolerate absence: the value arrives after the provider commits, so the consumer must render a fallback until then, and the handoff is typed only as far as the declared service interface. It cannot serve two needs: a component required synchronously at setup (a pane kind's Component, a slot registration made at commit), and build-time surface — named, versioned, tree-shakable, typed imports. Those two needs are what the export path below exists for. If a runtime handoff with a fallback is enough, provide a service and stop here.

2. Declare the entry: bb.appExports

A new manifest key names the entry file; it requires bb.app (the exports ride the app tier's lifecycle, CSS, and load gates):

{ "bb": { "app": "./src/app.tsx", "appExports": "./src/app-exports.ts", /* … */ } }

bb plugin build bundles it to dist/app-exports.mjs with the app tier's exact external rules (§4.11): React, the SDK entries, @bb/ui, and the rest of the import map stay bare; everything else bundles. The build records the module's named export list in meta.json as appExports: string[] and refuses a module with top-level statements other than declarations and re-exports — an export module is side-effect-free at import, always. There is no default export; names are the surface.

// src/app-exports.ts
export { DeployBadge } from "./components/deploy-badge.js";
export { useReleaseState } from "./hooks/use-release-state.js";

3. Import it: @get-bb/plugin-<id>/app

The consumer imports the sibling /app path — the same convention as /contracts, one entry per surface:

import { DeployBadge, useReleaseState } from "@get-bb/plugin-deploy/app";

The consumer's manifest must claim the import, with the exporter's package major:

{ "bb": { "contributes": { "appImports": { "deploy": 1 } } } }

The builder leaves a claimed @get-bb/plugin-<id>/app specifier bare and fails the build for an unclaimed one (today's rejection stays for the unclaimed case). TypeScript resolves the exporter's dist/types/app-exports.d.ts through the exporter package's ./app types condition — the same split as Guide 16: types from the package, bytes from the host.

4. How the bytes arrive: the import map, extended per plugin

Core serves each exporter's bundle at /plugins/app/<id>/app-exports.mjs?h=<digest> — digest-suffixed and immutable, unlike today's stable plugin asset URLs — and inlines one import-map entry per installed exporter into index.html:

{ "imports": { "@get-bb/plugin-deploy/app": "/plugins/app/deploy/app-exports.mjs?h=3f9c…" } }

Because the exports bundle keeps the map's specifiers external, it resolves react, @bb/ui, and @get-bb/plugin-sdk/app to the same hashed runtime URLs as every other bundle. One React, one Radix layer world, one zod — the identity guarantee costs nothing new; it is the same rule the app tier already lives under (§4.11).

An import map cannot change after module resolution starts. Consequences, stated plainly:

  • Installing or enabling a new exporter adds its entry on the next page load. Consumers installed later than their exporter work immediately; the reverse order needs the reload that installation already schedules.
  • An exporter artifact update reloads the page when any loaded plugin claims it. Reconcile cannot swap a mapped module in place; it falls back to the H1 rule (reload plus toast) rather than run consumers against stale bytes. An exporter update with no live consumers reloads nothing.

5. Version and absence rules

At plan time the loader checks every appImports claim:

Situation Consumer outcome
exporter installed, majors match loadable; entry in the map
exporter's major differs from the claim consumer app tier needs-update, detail names both versions
exporter absent or disabled consumer app tier not loadable, status degraded, detail missing_app_import: <id>
exporter present, appExports entry absent (older exporter) same as absent — the claim is against the surface, not the package

A static import is a hard dependency: the consumer's whole app tier stays down rather than throw at module scope. For an optional dependency, the SDK adds one helper:

importAppModule(pluginId: string): Promise<Record<string, unknown> | null>

It resolves through the same map entry and returns null when no entry exists — the injector-style soft path (Guide 17 step 1). A consumer that can render without the exporter uses importAppModule and a fallback; a consumer that cannot uses the static import and lets the loader gate it.

The name rule from Guide 17 applies unchanged: an export's name plus signature is the contract. Removing or retyping a named export is a major bump; the loader's major check turns that bump into needs-update instead of a runtime crash.

6. CSS: the exporter styles what the exporter paints

An exported component mounts inside the consumer's data-bb-plugin subtree, so the consumer's scoped utilities apply to it and the exporter's do not. The existing contributor rule (§4.11) already covers this: DOM painted for another plugin carries data-bb-plugin-effect="<contributor id>". For exports it becomes mandatory: every exported component renders a root carrying the exporter's effect attribute. The SDK supplies the spread so nobody hand-writes it:

export function DeployBadge(props: DeployBadgeProps) {
  return <span {...usePluginEffectProps()} className="rounded bg-canvas px-1">{props.label}</span>;
}

usePluginEffectProps() reads the defining bundle's plugin id — not the ambient mount — so the attribute is right even deep in the consumer's tree. The exporter's utilities are compiled into its own app.css with an added [data-bb-plugin-effect="<id>"] arm, and that sheet loads with the exporter's app tier, which the plan-time check guarantees is present whenever a consumer is loadable. Authored-CSS rules are unchanged: root everything at your own effect attribute.

7. Worker rows and the marketplace

Worker isolation does not touch this surface: only server and background entries run in the worker (§1.10); an exports bundle is browser code served exactly like the app bundle. What changes is review weight — an exported component runs inside other plugins' subtrees, with the exporter's effect scope. Marketplace review treats appExports like the app tier plus one question: does anything exported reach outside its own rendered root? The side-effect-free build check (step 2) closes the import-time channel; the render-time answer is review, the same as for any occupant.

What happens at runtime

  1. Boot: core reads every installed row's meta.json; rows with appExports gain a map entry with the artifact digest.
  2. Plan: appImports claims are checked (step 5 table); failures surface as row problems in bb composition dump, like any other plan-time degrade.
  3. Load: a consumer's bundle imports resolve through the map before its setup runs. The exporter's module evaluates once per page, shared by all consumers.
  4. Reconcile: exporter changed and a claimant is loaded → toast + page reload. Consumer changed → normal per-plugin reload; the mapped exporter module is untouched.

Pitfalls

  • Do not export a page. A pane kind plus a route already does that with arbitration, pins, and crash isolation (§4.5); an export has none of those.
  • Do not export schemas or pure data helpers here; that is what /contracts is for, and contracts load in every tier — exports load only in the browser.
  • Do not re-export react, @bb/ui, or anything already on the map. The build refuses it; the map is the single source for shared identity.
  • A hook that subscribes to the exporter's services still resolves them through the consumer's mount context — useService is ambient. Exported hooks must tolerate service_unavailable exactly like slot components (Guide 7, step 11).
  • importAppModule returns untyped members. Pair it with import type { … } from "@get-bb/plugin-<id>/app" so the soft path stays typed.
  • Expect page reloads on exporter updates during development; bb plugin dev on an exporter with live consumers is a reload loop by design. Develop the exporter's components inside its own app tier first, export once stable.

See also

  • Guide 7, UI slots, and Guide 17, Cross-plugin slots — composition without imports; prefer it when the declarer should own placement.
  • Guide 16, Typed slots — the same publish-types-from-the-package pattern this design reuses.
  • Reference §4.11 (import map, CSS rules), §4.16 (this design, normative), Appendix B (H9).