Skip to content

Shim clsx, tailwind-merge, cva and the shared icon onto plugin host slots (A5, partial) - #1897

Merged
SawyerHood merged 4 commits into
bb/mobile-perf/plugin-boot-deferfrom
bb/mobile-perf/plugin-host-slots
Aug 19, 2026
Merged

Shim clsx, tailwind-merge, cva and the shared icon onto plugin host slots (A5, partial)#1897
SawyerHood merged 4 commits into
bb/mobile-perf/plugin-boot-deferfrom
bb/mobile-perf/plugin-host-slots

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

Every plugin app bundle carried its own copy of libraries the host already ships (audit A5). zod (~516 KB raw / ~75 KB gzip) was bundled by four builtins, tailwind-merge + clsx by thirteen, class-variance-authority by most, and the shared-ui Icon with its ~110 KB hugeicons map by twelve. Phones parsed and evaluated all of it on every page load. RUNTIME_SLOT_BY_SPECIFIER in packages/plugin-build only shimmed react, the SDK, pierre, the portal radix families, sonner and vaul.

What changed

  • bb plugin build routes zod, clsx, tailwind-merge, class-variance-authority and @bb/shared-ui/icon to new globalThis.__bbPluginRuntime slots (zod, clsx, tailwindMerge, classVarianceAuthority, sharedUiIcon). shared-ui's own components import the icon module as ./icon; the esbuild plugin routes that import to the same slot when the importer lives in shared-ui's source tree. A plugin's vendored icon.tsx still bundles.
  • Shims forward a real default export (mod.default when the host namespace has one), so import clsx from "clsx" receives the callable, not the namespace.
  • Host (apps/app/src/lib/plugin-frontend.ts) installs the five slots. Export manifest regenerated (@bb/shared-ui/icon exports come from esbuild metadata like the SDK facade).
  • PLUGIN_SDK_VERSION 0.4.8 -> 0.4.9. Old bundles keep working (they carry their own copies); new bundles need the new host slots, so path/builtin plugins rebuild on the sdkVersion-differs trigger and scaffolds pin the floor.
  • Scaffold generator moves clsx/tailwind-merge/cva to type-only deps (zod stays a dependency: server.ts bundles it). Guide and bb-plugin-authoring skill updated. CLI scaffold-dependency test now applies the shim exemption only to app-side files.
  • apps/app/bundle-budget.json raised: exposing the whole zod namespace stops the boot chunk from tree-shaking zod exports the app never calls: boot payload 1655.7 KB / 449.1 KB brotli -> 1848.4 KB / 481.7 KB (+193 KB raw, +33 KB brotli). The other four slots add under 2 KB. Removing zod from RUNTIME_SLOT_BY_SPECIFIER reverts both sides of the trade.

Builtin app.js after rebuild: 8.87 MB -> 4.01 MB raw (-55%), 1.76 MB -> 0.93 MB gzip (-47%). Per plugin (raw): ask-user-question 784 KB -> 38 KB, automations 1063 -> 315, connect 376 -> 145, custom-instructions 99 -> 9, docs 1640 -> 1419, github 416 -> 185, inline-vis 239 -> 9, keep-awake 308 -> 79, memory 122 -> 30, provider-retry 249 -> 18, secrets 644 -> 37, side-chat 241 -> 11, tasks 2380 -> 1638, workflows 300 -> 69.

How you verified

  • New tests in packages/plugin-build/src/build-plugin-app.test.ts: every non-SDK slot has a manifest entry; a bundle importing the five libraries compiles with no node_modules and, executed against a fake runtime, yields the callable clsx default, z, twMerge, cva, Icon; shared-ui's relative ./icon import is shimmed while a plugin's own components/ui/icon.tsx still bundles; isSharedUiIconRelativeImport unit cases. apps/app/src/lib/plugin-frontend.test.ts asserts the new slots and their identity with the host modules.
  • pnpm exec turbo run typecheck lint for @bb/plugin-build, @bb/templates, @get-bb/plugin-sdk, @bb/domain, @bb/app, @bb/cli, @bb/server: green. pnpm exec turbo run test for @bb/plugin-build (24 passed), @bb/templates (44), @bb/plugin-registry (1), plugin-sdk version test, CLI plugin-build/plugin-new/packaged-plugin-build/plugin-guide-docs/plugin-dev-loop/docs- and github-official-plugin-bundle/plugin-scaffold-dependencies tests, server plugin-app-bundle + plugin-sdk tests, app plugin-frontend test: all green.
  • pnpm exec turbo run build --filter=@bb/app + node scripts/check-bundle-budget.mjs: passes with the ratchet.
  • Rebuilt all 18 plugin app bundles through buildPluginApp (sizes above).

Fixes: part of the mobile / iOS Safari performance program (verified sweep report in the bb thread; no single issue).

Stack context

Layer 18 of 22 in the bb/mobile-perf/* stack (bottom → top: quick wins first, big rocks last).

  • Prerequisite (layer below): bb/mobile-perf/plugin-boot-defer (Defer plugin frontend boot past first route paint with bounded, ordered loads (A3, B23) #1896).
  • Next layer: bb/mobile-perf/rerender-amplifiers (Remove re-render amplifiers across slots, contexts, banners and the prompt area (C6-C13, D2, D4, E9, E11, E12, H8) #1898).
  • Audit findings addressed: see title IDs. Review: approved-with-fixes; 1 review fix commit(s).
  • Partial: A5 (bundle splitting): not implemented (outdir + esbuild splitting + served chunks). Slots shipped; splitting plan described in the report.
  • Reviewer notes / follow-ups: A5 'splitting' (outdir + splitting, lazy definePluginApp components) is not implemented; the branch only covers the host-slot half of A5, as the implementer reported. | Design trade-off to be aware of, not a defect: exposing the full zod namespace on __bbPluginRuntime stops the host boot chunk from tree-shaking zod (+193 KB raw / +33 KB br on boot | Compatibility note (pre-existing pattern, same as the earlier @pierre/diffs slot): a plugin app built with the new bb plugin build throws 'Cannot load "zod"' if loaded by an older
  • Wire/contract: No server<->host-daemon wire change; HOST_DAEMON_PROTOCOL_VERSION untouched. Plugin runtime contract: five new slots on globalThis.__bbPluginRuntime (zod, clsx, tailwindMerge, classVarianceAuthority, sharedUiIcon); RUNTIME_SLOT_BY_SPECIFIER gains zod, clsx, tailwind-merge, class-variance-authority, @bb/shared-ui/icon; runtime shims now export ("default" in mod ? mod.default : mod) as default. PL

AGENT GENERATED: by Claude Opus 5

@SawyerHood SawyerHood changed the title bb/mobile perf/plugin host slots Shim clsx, tailwind-merge, cva and the shared icon onto plugin host slots (A5, partial) Aug 19, 2026
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/plugin-host-slots branch 2 times, most recently from 122a318 to e5ad888 Compare August 19, 2026 07:50
@SawyerHood
SawyerHood marked this pull request as ready for review August 19, 2026 08:04
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/plugin-host-slots branch from e5ad888 to e0df521 Compare August 19, 2026 15:59
SawyerHood and others added 4 commits August 19, 2026 16:10
Every plugin app bundle carried its own copy of libraries the host already
ships: tailwind-merge + clsx thirteen times, class-variance-authority, and
the shared-ui Icon with its ~110 KB hugeicons map twelve times. Phones
parse and evaluate all of it on every page load (audit finding A5).

`bb plugin build` now routes those four specifiers to new
`globalThis.__bbPluginRuntime` slots (`clsx`, `tailwindMerge`,
`classVarianceAuthority`, `sharedUiIcon`) the host installs in
installPluginRuntime. shared-ui's own components import the icon module
relatively (`./icon`), so the esbuild plugin also routes that import to the
same slot when the importer lives in shared-ui's source tree; a plugin's
vendored icon.tsx keeps bundling. Shims now forward a real default export
(`mod.default` when the host namespace has one) so `import clsx from "clsx"`
receives the callable rather than the namespace.

zod deliberately stays bundled per plugin: a slot exposes the whole module
namespace on the runtime object, which stops rolldown from tree-shaking the
zod exports the app itself never calls, and that cost +193 KB raw / +33 KB
brotli on the boot payload every phone downloads before first paint — a
worse trade than plugin bundles (which load after route paint) each
carrying their own ~490 KB raw copy.

Old bundles keep working: they still contain their own copies. New bundles
need a host with the new slots, so PLUGIN_SDK_VERSION moves to 0.4.9 (path
and builtin plugins rebuild on the sdkVersion-differs trigger; scaffolds
pin the floor).

Co-Authored-By: Claude <noreply@anthropic.com>
The plugin scaffold generator classifies registry item dependencies as
bundled or type-only from its own copy of the shim list; clsx,
tailwind-merge and class-variance-authority now join the type-only set
(zod is not a slot, so it stays a dependency: server.ts bundles its own
copy). Regenerated the starter dependency tables and the embedded guide.

Co-Authored-By: Claude <noreply@anthropic.com>
The CLI's end-to-end scaffold and official-plugin bundle tests execute
freshly built app bundles against a stub runtime. Vendored components call
cva() and cn() at module scope, which now resolve through the
classVarianceAuthority/clsx/tailwindMerge slots instead of bundled copies,
so the stubs must provide them (and the scaffold test stops linking those
packages, which would only hide a shim regression).

Co-Authored-By: Claude <noreply@anthropic.com>
…es list

The bullet above it now says those three are runtime-shimmed; the older
line still listed them as bundled from the plugin's node_modules.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/plugin-host-slots branch from e0df521 to 1b6bf16 Compare August 19, 2026 16:13
@SawyerHood
SawyerHood merged commit e977b3d into main Aug 19, 2026
14 of 23 checks passed
@SawyerHood
SawyerHood deleted the bb/mobile-perf/plugin-host-slots branch August 19, 2026 16:23
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…ed loads (A3, B23) (#1896)

## What was wrong

- Every running plugin's app bundle (about 3 MB unminified JS plus about
780 KB CSS across the default set) started importing as soon as
`/system/config` resolved, all at once via `Promise.all`, each injecting
its own `<link rel=stylesheet>` on arrival. On a phone this parse/eval
and the dozen style invalidations landed in the same window in which the
route chunk was still downloading, so first route content painted late.
(Audit finding A3.)
- The workflows composer banner called `workflowActiveRuns` once per
second on every open thread for as long as it was mounted, whether the
thread had a run and whether the tab was visible. (Audit finding B23.)

## What changed

- `usePluginFrontendBoot` waits for system config, then for the first
route content to commit (`RouteContentPaintSignal`, a sibling of the
lazy routes inside their Suspense boundary) plus an idle slot
(`requestIdleCallback`, or two animation frames on WebKit), bounded by a
1.5 s timeout. A `/plugins/:pluginId/...` route boots as soon as config
resolves because the plugin is the page there.
- `reconcilePluginFrontends` imports at most three bundles at a time.
The plugin that owns the current panel route goes first, then ascending
bundle size. The server now reports `jsBytes` per app bundle
(`pluginAppStateSchema`, `GET /api/v1/plugins`).
- Plugin stylesheet insertions coalesce into one animation frame
(`createBatchedPluginCssApplier`); removals stay synchronous and cancel
a pending insertion. The pagehide teardown wiring is untouched.
- Workflows: the service publishes a `workflow-runs` realtime signal for
the origin thread on run create, claim, settle, and cancel. The banner
subscribes and refreshes on a matching signal. Banner and preview
directive poll once per second only while a run is active (or the last
fetch errored) and `document.visibilityState` is not hidden, with one
catch-up refresh when the tab or the realtime connection comes back.

## How you verified

- New tests (fail before, pass after):
`apps/app/src/lib/plugin-frontend-boot-schedule.test.ts` (paint+idle vs
timeout vs cancel), `apps/app/src/hooks/usePluginFrontendBoot.test.tsx`
(no boot on config alone; boot after paint+idle; boot at 1.5 s;
immediate boot on plugin panel route),
`apps/app/src/lib/plugin-frontend-load-order.test.ts` (ordering, max-3
concurrency and lane hand-off, failure isolation, CSS batching/removal),
`plugins/workflows/src/app.test.tsx` (idle thread never polls, realtime
signal triggers one refresh and polling starts/stops with active runs;
hidden document pauses polling and refreshes once on return),
`plugins/workflows/src/service-policy.test.ts` (signal published on
start/claim/cancel, not on a no-op stop),
`apps/server/test/services/plugins/plugin-app-bundle.test.ts` (jsBytes
equals dist/app.js size).
- `pnpm exec turbo run typecheck` for @bb/app, @bb/server,
@bb/server-contract, @bb/templates, @bb/cli, @bb/sdk,
bb-plugin-workflows: pass. `pnpm exec turbo run lint --filter=@bb/app`:
0 errors, no warnings in touched files. `pnpm exec turbo run build
--filter=@bb/app` + `node scripts/check-bundle-budget.mjs`: bundle
budget OK (449.2 KB br boot). Targeted vitest runs listed above: all
pass.

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 17 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below): `bb/mobile-perf/plugin-build-minify`
(#1895).
- Next layer: `bb/mobile-perf/plugin-host-slots` (#1897).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
1 review fix commit(s).
- Reviewer notes / follow-ups: The package title mentions a 'pageshow
reboot' (audit J5: tear down plugin frontends only when
!event.persisted, and re-boot on a persisted pageshow) but the branch
does not implem | Design trade-off to be aware of (not a bug): reconcile
now runs 3 lanes over import + setup + content-script mount. A
third-party plugin whose bundle import stalls on a bad network |
Contract note: /api/v1/plugins app.bundle.jsBytes is required and the
browser-side isFrontendBundle guard drops bundles without it. App,
SDK/CLI, and server ship together so this i
- Wire/contract: GET /api/v1/plugins (and plugin reload/list responses)
app.bundle gains a required `jsBytes: number` field
(packages/server-contract pluginAppStateSchema, apps/server
app-bundle.ts, apps/app isFrontendBundle guard). This is
server<->browser only; nothing on the server<->host-daemon wire changed,
so HOST_DAEMON_PROTOCOL_VERSION was not bumped. Regenerated tracked
artifacts: packages/plugin-sdk/bund

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…rompt area (C6-C13, D2, D4, E9, E11, E12, H8) (#1898)

## What was wrong

Several client-side amplifiers made phones re-render far more than the
data changed:

- The plugin slot store rebuilt all 14 slot arrays per plugin activation
and emitted once per bundle at boot (~18 app-wide passes; every markdown
body re-parsed once directives existed).
- `useNavigate()` under `<BrowserRouter>` rebuilds per pathname, so the
thread-actions context, the fork handler in the timeline static context,
and every sidebar row (`useThreadRowSplitDrag`,
`useCreateThreadInWorktree`, environment archive) re-rendered on every
navigation.
- `MessageActionBar` mounted the hidden desktop tooltip bar on touch
phones in addition to the mobile popover.
- Every `ConversationRow` re-rendered per new message via the
latest-actionable id contexts, and React Compiler skipped it (a
ref-callback passed as `ref` typed the edit object as a ref).
- Title-mention resource maps were rebuilt twice (AppLayout +
ProjectList) per sidebar refetch and provided twice.
- Sidebar rows subscribed to `splitLayoutAtom` on compact viewports
where splits are off.
- Draft presence re-read localStorage for every thread per keystroke and
per sidebar render.
- `useChildThreadPendingAttention` returned a fresh array per render,
invalidating the memoized prompt stack.
- `ThreadDetailPromptArea` failed React Compiler compilation
(try/finally, render-time ref writes, ref-typed closures).
- Workflow/todo/background cards mounted collapsed bodies with per-row 1
s timers.
- `experimental_useSidebarThreads` remapped every entry into a fresh DTO
per update.
- Page header and retained secondary panel re-rendered on every
SidebarContext commit for one boolean.

## What changed

- Plugin slot store: per-kind structural sharing, per-plugin flattened
slot cache, and a bounded notification batch (150 ms) around reconcile.
- `useRouteNavigate()` from `RouteNavigationProvider` (ref-held
navigate, stable identity, no location subscription) used by rows,
ThreadActionsProvider, fork handler and plugin sidebar hooks; fork
handler reads the thread from a ref.
- Mobile branch in MessageActionBar (popover or plain inline buttons);
desktop unchanged.
- `ConversationRow` = thin context reader +
`memo(ConversationRowContent)`; `InlineMessageEditorHost` isolates the
ref callback so both compile.
- `buildThreadTitleMentionResources` with value retention; one provider
(AppLayout); ProjectList reads the context.
- Split indicator subscribes to a null atom when compact/disabled.
- Presence store caches bits, re-reads only the changed key, stays
silent when presence does not flip.
- Module-level `combine` + frozen empty array for child attention.
- ThreadDetailPromptArea compiler bailouts removed
(`runWhileFollowUpShortcutSending`, `useLatestRef`, layout-effect ref
write, module-level composer-host accessors).
- Shared `AnimatedBody` (realize on first expand, retain) for the three
cards; `useSecondTick` shared ticker.
- WeakMap DTO memo for plugin sidebar threads; SDK docs note the
uncapped array and windowing expectation.
- `SidebarShowingContext` boolean for
`useIsSidebarShowing`/`useOptionalIsSidebarShowing`.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/app
--filter=@get-bb/plugin-sdk --filter=@bb/plugin-build`: pass.
- `pnpm exec turbo run lint --filter=@bb/app`: 0 errors (pre-existing
compiler warnings only).
- `pnpm exec turbo run test --filter=@bb/app`: 370 files, 2910 passed, 3
skipped. `@get-bb/plugin-sdk` tests: 103 passed. `@bb/plugin-build`
tests: 20 passed.
- New tests fail before / pass after: plugin-slots structural sharing +
batching, ThreadActionsProvider.navigation (stable context and
useRouteNavigate reader across a MemoryRouter navigation),
useForkThreadFromMessage identity, MessageActionBar mobile branch,
ThreadTimelineRows.row-isolation, ThreadTitleMentions.resources
retention, paneContentSplitIndicator compact gating,
usePromptDraftStorage presence reads/renders,
child-thread-pending-interactions.hook identity, AnimatedBody
realize/retain + shared interval, plugin-sidebar-hooks DTO identity,
sidebar showing-bit isolation.
- babel-plugin-react-compiler@1.0.0 run over ThreadDetailPromptArea.tsx
and ThreadTimelineRows.tsx: `ThreadDetailPromptArea` (452 slots),
`ConversationRow`, `ConversationRowContent` now CompileSuccess.

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 19 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below): `bb/mobile-perf/plugin-host-slots`
(#1897).
- Next layer: `bb/mobile-perf/markdown-streaming-and-containment`
(#1899).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
1 review fix commit(s).
- Deliberately not done here: D6: already implemented on the base commit
dde6cdd (#1857 memoizes initialEditorContent on [richTextEditing]);
verified, no change needed
- Partial: E12: only the showing-bit context was split out (page header
+ retained ThreadSecondaryPanel); the full actions/state split of
SidebarContext was left as a foll; E11: compact gating only; the
optional desktop per-content derived-atom refinement and the
SplitThreadArea reconcile short-circuit were not done
- Reviewer notes / follow-ups: C6 batching: while a slot batch is open
(<=150 ms) a component that re-renders for unrelated reasons reads the
fresh snapshot via getPluginSlotSnapshot() while un-rendered subscrib |
E12 is a partial split (only the sidebar-showing boolean got its own
context); the actions/state split from the audit remains a follow-up as
the implementer stated. | C7 test is a hook-identity test rather than
the Profiler-through-ThreadDetailView test the audit suggested; the
row-isolation test in ThreadTimelineRows.row-isolation.test.tsx cove |
apps/app/.ladle is not covered by tsc/lint; the RouteNavigationProvider
addition there was checked by inspection only.
- Wire/contract: None. No server/daemon wire, host RPC, or protocol
changes; HOST_DAEMON_PROTOCOL_VERSION untouched. Public plugin SDK: only
doc comments on the existing experimental_useSidebarThreads member
(bundled-types regenerated); no new API members. Internal app contract:
PluginFrontendReconcileDeps gained a required beginSlotBatch dep (test
doubles updated).

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant