Shim clsx, tailwind-merge, cva and the shared icon onto plugin host slots (A5, partial) - #1897
Merged
SawyerHood merged 4 commits intoAug 19, 2026
Conversation
SawyerHood
force-pushed
the
bb/mobile-perf/plugin-host-slots
branch
2 times, most recently
from
August 19, 2026 07:50
122a318 to
e5ad888
Compare
SawyerHood
marked this pull request as ready for review
August 19, 2026 08:04
SawyerHood
force-pushed
the
bb/mobile-perf/plugin-host-slots
branch
from
August 19, 2026 15:59
e5ad888 to
e0df521
Compare
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
force-pushed
the
bb/mobile-perf/plugin-host-slots
branch
from
August 19, 2026 16:13
e0df521 to
1b6bf16
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Iconwith its ~110 KB hugeicons map by twelve. Phones parsed and evaluated all of it on every page load.RUNTIME_SLOT_BY_SPECIFIERin packages/plugin-build only shimmed react, the SDK, pierre, the portal radix families, sonner and vaul.What changed
bb plugin buildrouteszod,clsx,tailwind-merge,class-variance-authorityand@bb/shared-ui/iconto newglobalThis.__bbPluginRuntimeslots (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 vendoredicon.tsxstill bundles.mod.defaultwhen the host namespace has one), soimport clsx from "clsx"receives the callable, not the namespace.apps/app/src/lib/plugin-frontend.ts) installs the five slots. Export manifest regenerated (@bb/shared-ui/iconexports come from esbuild metadata like the SDK facade).PLUGIN_SDK_VERSION0.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.apps/app/bundle-budget.jsonraised: 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. Removingzodfrom 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
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./iconimport is shimmed while a plugin's owncomponents/ui/icon.tsxstill bundles;isSharedUiIconRelativeImportunit cases.apps/app/src/lib/plugin-frontend.test.tsasserts the new slots and their identity with the host modules.pnpm exec turbo run typecheck lintfor @bb/plugin-build, @bb/templates, @get-bb/plugin-sdk, @bb/domain, @bb/app, @bb/cli, @bb/server: green.pnpm exec turbo run testfor @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.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).bb/mobile-perf/plugin-boot-defer(Defer plugin frontend boot past first route paint with bounded, ordered loads (A3, B23) #1896).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).("default" in mod ? mod.default : mod)as default. PL