Skip to content

Cut the web app boot payload by 60% - #1071

Merged
SawyerHood merged 1 commit into
mainfrom
bb/app-boot-payload
Aug 6, 2026
Merged

Cut the web app boot payload by 60%#1071
SawyerHood merged 1 commit into
mainfrom
bb/app-boot-payload

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

Closes #1063.

The entry chunk statically pulled 3.97 MB of JavaScript before first paint. That is what makes bb feel slow on a phone: parse and compile happen on every load, and the HTTP cache does not help with them.

The issue's diagnosis was half right

The 2.3 MB workspace-checkout-display chunk was real. Shiki was not the cause — its grammars are already lazy (300+ per-language chunks behind import() thunks), and Shiki plus Oniguruma were only 164 KB, 7.3% of that chunk. Tree-shaking the grammar registry would have won about 30 KB.

What actually caused it

Every heavy dependency entered through one edge, confirmed by dumping rolldown's real module graph rather than reading imports:

main.tsx → App.tsx → usePluginFrontendBoot → plugin-frontend.ts

plugin-frontend.ts is the plugin runtime shim. It imports every library a plugin may resolve at runtime — React, the portal Radix families, sonner, vaul, @pierre/diffs (Shiki behind it) — plus plugin-sdk-app-impl, which reaches the promptbox editor and the markdown renderer. Its own docstring said the load "never delays first paint". The static import meant it did.

Three changes:

  1. plugin-frontend-lazy.ts — dynamic-imports the runtime and mirrors its bootPromise === null guard, so a realtime plugins-changed broadcast cannot pull the chunk back onto the critical path.
  2. realtime-cache-registry.ts — routed through the same door; it was a second eager edge.
  3. thread-activity.ts — a barrel leak. The sidebar thread list imported one predicate from the timeline barrel and got the whole timeline. Now imports the defining leaf module.

Result

raw brotli
before 3.97 MB 1005 KB
after 1.58 MB 423 KB
−60% −58%

Tiptap, ProseMirror, @pierre/diffs, Shiki and KaTeX are no longer statically reachable from the entry. What remains is app shell: react-dom (175 KB), sidebar, router, zod.

Keeping it

bundle-budget.json plus a CI step. The byte ratchet matters less than the package list — each of these reached the boot path through a barrel re-export, which typecheck and lint cannot see. bb:bundle-stats writes the boot chunk closure and the packages in it; check-bundle-budget.mjs fails on either limit and prints the offending chunk.

I verified the check fails correctly by reintroducing the barrel import: it caught it and named every package. why-eager.mjs prints the exact static import chain from the entry to a package.

No service worker

The issue also asked for one. I built it, then dropped it. /assets/ already carries max-age=31536000, immutable, so the marginal benefit was eviction resistance and an offline shell — and bb is useless offline. Against that, building it surfaced two real bugs (cache poisoning via the SPA fallback, and the desktop's launch-time cache clear not reaching a worker registration), and a wedged worker is hard for a phone user to clear while bb self-updates its server. The full win is banked without it.

One fix from that work is kept on its own merits: a missing /assets/ file now returns 404 instead of falling through to the SPA fallback, which answered with index.html at status 200 and made the browser report a MIME type error rather than the missing file.

Tests

  • turbo run test --filter=@bb/app --filter=@bb/server — 2368 app tests, 1334 server tests pass. Typecheck and lint clean.
  • Pre-existing failures not introduced here, identical on a stashed baseline: 1 server test (internal-skill-trees) and 4 desktop test files.
  • Production build driven in a real browser against a dev server: app boots with no console errors, the plugin runtime installs with all 16 SDK members, all 6 running plugins fetch their bundles, and the lazily loaded Tiptap editor mounts and accepts typing.

Risks

The plugin runtime now loads one microtask later. Plugin frontends already booted asynchronously after /system/config resolved, so nothing that depended on it ran earlier than the dynamic import resolves — verified end to end in the browser above.

🤖 Generated with Claude Code

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

I am SlopCop. I am reviewing this pull request under the review rule.

The stack contains one pull request: #1071. I found no draft or unauthorized stack pull requests to skip.

I will run parallel security, code-quality and architecture, and performance reviews. I will then test the top pull request end to end.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

Security review complete. I found no actionable security issue.

The review covered file and path handling, server exposure, CI execution, package detection, and plugin loading boundaries. The exact-head check, script syntax checks, Turbo graph check, and generated bundle-budget check passed.


const { sharedViteConfig } = await import(path.join(appDir, "vite.config.ts"));

/** id -> { static: string[], dynamic: string[] } */

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The documented diagnostic command fails before it can build.

node scripts/why-eager.mjs @tiptap/core imports vite.config.ts through Node. That config imports ./vite-bundle-stats.js, but only vite-bundle-stats.ts exists. Native Node does not remap the .js specifier, so it exits with ERR_MODULE_NOT_FOUND. Please load the config through Vite’s config loader, or use a Node-compatible module path. Add a smoke test for the documented command.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

End-to-end phase complete on the top pull request, #1071.

The app loaded at the exact PR head. The onboarding dialog, home route, and Settings route worked. The browser reported no console or page error. The app typecheck and the server static-cache test also passed.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

Code-quality and architecture review complete.

The phase found five actionable issues. The lazy plugin loader cannot recover after its chunk import fails. The Brotli check can pass with missing output. The documented eager-import command does not run. CI omits the new Vite plugin from type and lint checks. The new asset-404 branch lacks a regression test.

The repository scan found no larger duplicate implementation that should replace this design.

@@ -0,0 +1,81 @@
import { mkdir, writeFile } from "node:fs/promises";

@SawyerHood SawyerHood Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — CI does not type-check or lint this new TypeScript file.

The app TypeScript configuration includes only the app directories. The lint script limits ESLint to src. Vite can remove these types during a build, so the normal CI tasks do not verify this plugin’s TypeScript contracts. Add this file to the app TypeScript and lint inputs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/reviewtsconfig.node.json reaches this file through vite.config.ts, but CI never runs that configuration.

The current typecheck script runs only tsconfig.json. The lint script still limits ESLint to src. Run the node TypeScript configuration in CI and lint the root Vite files.

*/
type PluginFrontendModule = typeof import("./plugin-frontend");

let modulePromise: Promise<PluginFrontendModule> | null = null;

@SawyerHood SawyerHood Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The lazy loader keeps a rejected module promise forever.

If the chunk import fails once, modulePromise remains rejected. The boot hook does not catch that rejection. Later plugin-change events reuse the same promise, so plugin frontends cannot recover without a page reload. Clear the cached promise after rejection, contain the outer failure, and add a reject-then-resolve retry test.

Comment thread apps/server/src/server.ts
// single-page-app fallback would answer it with index.html at status
// 200, and the browser would report a confusing MIME type error for a
// script instead of a plain 404. Mirrors the /api/v1/* guard above.
if (urlPath.startsWith("/assets/")) {

@SawyerHood SawyerHood Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The new missing-asset response has no regression test.

The static-cache test checks an API miss, but it does not exercise this new /assets/ branch. Add a request for /assets/missing.js. Assert status 404 and confirm that the response does not contain index.html.


let bootBytes = 0;
let bootBrotliBytes = 0;
for (const chunk of stats.bootChunks) {

@SawyerHood SawyerHood Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A missing Brotli file counts as zero bytes and can make the budget pass.

I ran the checker against a missing output directory. It reported 0.0 KB brotli and passed that limit. This behavior can hide missing output or a chunk that precompression skipped. Reject a missing output directory and account for every boot chunk. Use the raw transfer size when no .br file exists, or fail with a clear error.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

🚨 SLOP COP 🚨 · review

Performance review complete.

The phase confirmed the broken diagnostic command and the missing-Brotli accounting issue. It also found that every startup still loads the full plugin runtime after paint, even when no plugin needs it. The new raw and compressed limits also allow approximately 8% and 10% growth.

The generated boot closure matched the HTML entry and preload links. The current bundle remains within its configured limits.

Comment thread apps/app/bundle-budget.json Outdated
"re-export rather than a direct import, which type checking cannot catch,",
"so the check names them explicitly."
],
"maxBootBytes": 1782579,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The new limits allow too much growth for a ratchet.

The generated head uses 1,657,115 raw bytes and 432,822 Brotli bytes. These limits allow approximately 7.6% raw growth and 10.1% compressed growth. Lower both limits near a stable CI measurement and state a small tolerance.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The linked issue sets stricter limits than this comment first stated.

Issue #1063 requires a 1.5 MB initial-payload limit and a 500 KB uncompressed per-chunk limit. The generated head uses 1,657,115 bytes, and its largest boot chunk uses 717,117 bytes. The check does not enforce a per-chunk limit. Please enforce both requested limits and add failure tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not enforcing either limit from #1063 — both are round numbers the issue picked without measuring, and I think both are wrong.

500 KB per chunk. I ran that rule against this branch: 8 chunks fail it.

2096KB  workspace-checkout-display-*.js
 814KB  worker-portable-*.js
 770KB  SplitWorkspaceRoute-*.js
 762KB  emacs-lisp-*.js
 700KB  atomFamily-*.js
 611KB  cpp-*.js
 608KB  wasm-*.js
 580KB  chunk-NNHCCRGN-*.js

emacs-lisp, cpp and wasm are on-demand Shiki grammars; worker-portable is a web worker. None of them are downloaded unless something asks for them. A per-chunk cap over dist/assets would fail CI on day one and push people to split chunks that block nothing.

Restricting it to boot chunks does not save the idea either. Parse and compile cost scales with total bytes on the boot path, not with how they are divided. Splitting the 717 KB boot chunk into two 360 KB chunks would satisfy the cap, change the payload by zero bytes, and cost one extra request. I have written that reasoning into bundle-budget.json so the omission is explicit rather than an oversight.

1.5 MB total. The head is 1,657,327 bytes. Getting under 1.5 MB means removing another 160 KB, and what remains is app shell: react-dom is 175 KB on its own, then the sidebar, router, zod, @hugeicons, query-core. There is no barrel left to cut — I verified with why-eager.mjs that Tiptap, ProseMirror, @pierre/diffs, Shiki and KaTeX are all zero eagerly-reachable modules. Hitting 1.5 MB would mean deferring genuine first-paint UI, which trades a real regression for an arbitrary number.

What I did instead is tighten the ratchet to 3% over the measured payload (1,707,047 raw / 445,742 brotli), down from the 7.6% / 10.1% you flagged. That is the part of your comment I think is right, and it now binds much harder than a 1.5 MB ceiling would.

let modulePromise: Promise<PluginFrontendModule> | null = null;

function loadPluginFrontend(): Promise<PluginFrontendModule> {
modulePromise ??= import("./plugin-frontend");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Every startup still imports the full plugin runtime, even when no plugin needs it.

The boot effect starts after system configuration resolves. This wrapper then imports the heavy shared runtime before it checks the plugin inventory. That work leaves the first-paint closure, but it can still delay the first input on a mobile device. Fetch the small inventory first. Import the heavy runtime only when at least one frontend plugin requires it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The generated post-paint closure adds approximately 2.5 MB raw.

An inventory-first check helps installations without frontend plugins. It does not help an installation with one frontend plugin. Keep diff and Shiki code unloaded until a plugin or user action needs those packages. Add browser checks for empty and non-empty frontend inventories.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one for a separate change, for two reasons.

Inventory-first does not help the installations that matter. You noted this yourself in the reply: it helps installations with no frontend plugins. bb ships 8 bundled plugins, and a default install runs 6 of them with app bundles — I confirmed that on a fresh dev data dir, where all 6 fetched their app.js. So the check would take the slow path for nearly every real user while adding plugin-inventory knowledge to the boot chunk, which is the layering this PR is trying to keep clean.

The deeper suggestion is a plugin SDK contract change, not a bundling change. Keeping diff and Shiki code unloaded until a plugin uses them means the runtime shim can no longer resolve those modules synchronously. The shim exists precisely so a plugin bundle's import { ... } from "@pierre/diffs" resolves at eval time (installPluginRuntime assigns the whole map to globalThis.__bbPluginRuntime). Making entries async would change how every plugin bundle is built and evaluated. That is worth doing, but it belongs in a change that can carry the SDK migration, not in a code-splitting PR.

Worth being precise about the cost, too: this load is post-paint and post-/system/config, so it is outside the budget this PR enforces and outside first paint. It can still compete for main-thread time on a phone, which is a fair point — it is just a different problem from the one here, and this PR already took 2.39 MB off the path that blocks rendering.

I have filed nothing yet; say the word and I will open an issue describing the async-shim work so it does not get lost.

@SawyerHood SawyerHood left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

Review complete for the one-PR stack: #1071.

High:

  • The budget misses the limits from issue #1063. The head uses 1,657,115 bytes total and a 717,117-byte boot chunk. The required limits are 1.5 MB total and 500 KB per chunk.
  • Startup still imports approximately 2.5 MB of plugin runtime after paint. It does this before the inventory proves which packages a plugin needs.

Medium:

  • Missing Brotli files count as zero and can make the budget pass.
  • A failed lazy runtime import stays cached and cannot retry.

Low:

  • The documented why-eager.mjs command fails with ERR_MODULE_NOT_FOUND.
  • CI does not run tsconfig.node.json or lint the root Vite files.
  • The new missing-asset 404 branch has no regression test.

The security phase found no actionable issue. The exact-head app build, configured budget check, app typecheck, targeted server test, and browser smoke test passed. The browser loaded the home and Settings routes without a console or page error.

An independent GPT-5.6 gate confirmed all seven unique findings. I submitted this review as a comment only.

@SawyerHood
SawyerHood force-pushed the bb/app-boot-payload branch from 21bd44d to 8644a73 Compare August 6, 2026 14:02
The entry chunk statically pulled 3.97 MB of JavaScript before first paint,
which is what made bb feel slow on a phone: parse and compile happen on every
load, and the HTTP cache does not help with them.

Every heavy dependency entered through one edge. App renders
usePluginFrontendBoot, which statically imported plugin-frontend — the plugin
runtime shim that imports every library a plugin may resolve at runtime
(React, the portal Radix families, sonner, vaul, @pierre/diffs and Shiki
behind it) plus plugin-sdk-app-impl, which reaches the promptbox editor and
the markdown renderer. Its own docstring said the load never delays first
paint; the static import meant it did.

Route it through plugin-frontend-lazy, which dynamic-imports the module and
mirrors its "boot never started, nothing to reconcile" guard so a realtime
plugins-changed broadcast cannot pull the chunk back onto the critical path.
A failed chunk fetch drops the cached promise instead of replaying the same
rejection forever, and a later broadcast retries the boot that never
happened. realtime-cache-registry took the same door; it was a second eager
edge.

thread-activity then remained as a barrel leak: the sidebar thread list
imported one predicate from the timeline barrel, and the barrel brought the
whole timeline with it. It now imports the defining leaf module.

Boot payload: 3.97 MB -> 1.58 MB raw, 1005 KB -> 423 KB brotli. Tiptap,
ProseMirror, @pierre/diffs, Shiki and KaTeX are no longer statically
reachable from the entry.

Add a CI budget so this does not regress. The byte ratchet matters less than
the package list: each of those dependencies reached the boot path through a
barrel re-export, which typecheck and lint cannot see. bb:bundle-stats writes
the boot chunk closure and the packages in it, and check-bundle-budget fails
on either limit, on a missing dist, or on a boot chunk with no .br file that
would otherwise weigh zero against the compressed budget. why-eager.mjs
prints the exact static import chain from the entry to a package by dumping
rolldown's real module graph. The root Vite plugin files join the app's
typecheck and lint inputs, which never covered them before.

Also return 404 for a missing /assets/ file instead of falling through to the
single-page-app fallback, which answered with index.html at status 200 and
made the browser report a MIME type error rather than the missing file.

Co-Authored-By: Ben Charney <227467004+toasterman234@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/app-boot-payload branch from 8644a73 to ab24e47 Compare August 6, 2026 14:08
@SawyerHood
SawyerHood merged commit b6c6ded into main Aug 6, 2026
10 checks passed
@SawyerHood
SawyerHood deleted the bb/app-boot-payload branch August 6, 2026 14:13
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.

Mobile lag: Shiki grammar registry bundles 2.3MB of JS into workspace-checkout-display, no service worker for caching

1 participant