Skip to content

chore(frontend): cut frontend bundle size (code-shiki, nextstepjs, api-client)#4864

Draft
ardaerzin wants to merge 5 commits into
mainfrom
fe-chore/bundle-size-optimizations-06-2026
Draft

chore(frontend): cut frontend bundle size (code-shiki, nextstepjs, api-client)#4864
ardaerzin wants to merge 5 commits into
mainfrom
fe-chore/bundle-size-optimizations-06-2026

Conversation

@ardaerzin

@ardaerzin ardaerzin commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

Independent fixes that cut first-load JS, each found via an ANALYZE=true production build of web/oss. Split into two waves: the first three target the heaviest editor/onboarding/api chunks; the next three evict the page-specific provider, auth, and serialization code that was riding the shared _app chunk on every page.

1. Lazy-load @lexical/code-shiki (the big one)

Its published prod build is a single ~8.7 MB file with every Shiki grammar + theme inlined as data (not tree-shakeable). Two static imports forced it into the synchronous first-load graph of every editor-bearing page. Converted the tokenizer (@agenta/ui) and DynamicCodeBlock imports to lazy import(); both already had Prism/plaintext fallbacks, so highlighting upgrades to Shiki once the async chunk resolves.

2. Lazy-load @agentaai/nextstepjs

The onboarding tour renderer (~136 kB: NextStepReact + motion/spotlight) sat in the shared _app chunk on every page, though tours only run during onboarding. It locates targets via document.querySelector and renders through a portal, so it does not need to wrap the app tree — children now render directly and the renderer mounts as a lazy childless sibling. The lightweight NextStepProvider context stays static. Required marking the package sideEffects: false (pnpm patch) so the provider's barrel import no longer drags the renderer back into _app.

3. Split @agentaai/api-client per-resource

The Fern-generated root AgentaApiClient statically imports all 27 resource clients (~515 kB), and it was instantiated at module scope in _app purely to pin the host — pulling the whole client into the shared chunk. Resource clients self-normalize auth in their own constructors (same BaseClientOptions), so they can be constructed directly. Added sideEffects: false + ./resources/* subpath exports to the client, a config/resources layer in @agenta/sdk (host pin via configureAgentaSdk + per-resource lazy accessors, no monolith import), and migrated _app + the entities api modules to per-resource accessors. init/getAgentaSdkClient stay for compatibility.

4. Defer the playground/entity boot cluster off _app

GlobalStateProvider and AppGlobalWrappers statically pulled a heavy registration graph into _app on every page: WebWorkerProvider → playground execution state, the workflowEntityBridge side-effect import, the selection-system init + four modal adapters, and the playground/variant URL-sync slices. Every one of these is consumed only at user-action time (worker bridge, commit/archive callbacks, selection adapters resolved via a runtime Map), so they need to land before the first relevant interaction, not before render. Moved them into a single childless DeferredAppBoot module loaded via next/dynamic({ssr:false}) (mounted on first client paint), and made the two URL-sync slices lazy import() behind a queue. The registries already tolerate "not yet registered" (lookups return undefined), so the worst case is a skipped orchestration, never a crash.

5. Lazy-load the SuperTokens frontendConfig

frontendConfig statically imports the emailpassword/passwordless/thirdparty recipes (the prebuilt-UI-bearing modules). SuperTokensReact.init already runs post-mount inside an effect gated by isInitialized, so importing the config lazily there keeps those recipes out of _app with zero behavioural change. The session recipe stays eager via useSession.

6. Move YAML serialization out of the helper barrel

getYamlOrJson lived in lib/helpers/utils.ts — a widely-imported barrel — so its static js-yaml import (~37 kB) rode _app everywhere. Extracted it to lib/helpers/yamlFormat.ts and repointed its sole caller; js-yaml now loads only with the drawers/editors that format YAML.

Impact (Next.js "First Load JS shared by all", gzipped — the every-page download)

Stage Shared First Load Δ
main baseline 1.07 MB
Fixes 1–3 (code-shiki, nextstepjs, api-client) 1.01 MB −60 kB
Fix 4 — boot-cluster deferral 910 kB −100 kB
Fix 5 — SuperTokens lazy config 871 kB −39 kB
Fix 6 — js-yaml eviction 859 kB −12 kB
Total 859 kB −211 kB (~20%)

Editor pages (eval results, testset detail, evaluators playground, annotation queue) drop from ~10.7 MB to ~2.35 MB raw first-load, since the 8.5 MB code-shiki chunk is now async. _app parsed size: 3.20 MB (main) → ~2.44 MB.

Verification

  • ANALYZE=true next build exits 0; tsc --noEmit clean on @agenta/sdk and @agenta/entities; pnpm lint-fix clean.
  • Each stage verified by grepping the rebuilt _app chunk to confirm the target module left it (0 markers) and reading the regenerated First Load number.
  • Behavioural note for review: code blocks briefly render via the Prism/plaintext fallback before Shiki highlighting kicks in on first editor mount per session (pre-existing fallback path, now exercised on first load). The deferred boot registrations land on first client paint, before any navigation into a playground/eval page completes its data fetch.

Why we stopped here — the app-query / entity-state core was left untouched (deliberately)

~500 kB parsed (~50–80 kB gzipped) still sits in _app: the workflow/testset entity molecules, the app-list query atoms (state/app/atoms/fetcher.tsappWorkflowsListQueryAtom), the app-level InfiniteVirtualTable, and the eval-run atoms. We left it on purpose:

  • It's entangled through core navigation state. The app-list query (which apps exist — needed by the shell) statically imports the full @agenta/entities/workflow barrel (store + molecule + api), and the entity paginated stores pull the table. Evicting it requires splitting that barrel so the lightweight query doesn't drag the whole graph — a multi-file package refactor touching 60+ consumers, with added latency on the apps page every user hits. Deferring just the selection-system init was tried earlier and reverted (moved ~50 kB with picker-registration risk).
  • The gzipped payoff doesn't justify it. This is our own repetitive atom code (compresses ~0.4), so the real download win is the low end of that range for a high-effort, higher-risk change to core data state. Better scoped as its own project with a measured before/after than bundled into this PR.
  • A per-resource SDK client split was evaluated and dropped. Splitting @agenta/sdk's resource barrel moved the evaluations/workflows/testsets Fern clients out of _app (−101 kB parsed), but only −0.5 kB gzipped — Fern codegen is highly repetitive and gzip already crushes it. Not worth the churn for the download metric, so it was reverted. The lesson now baked in: rank bundle targets by gzipped delta, not parsed bytes.

The wins in this PR are exactly the inverse — real, high-entropy third-party libraries (code-shiki, nextstepjs, supertokens recipes, js-yaml) and deferrable registration code, where parsed and gzipped savings align.

Two eagerly-bundled dependencies dominated first-load JS:

- @lexical/code-shiki ships a single ~8.7 MB file (every Shiki grammar +
  theme inlined as data, not tree-shakeable). Two static imports forced it
  into the synchronous first-load graph of every editor-bearing page, so
  evaluation results / testset detail / evaluators playground / annotation
  queue each shipped ~10 MB of raw JS up front. Convert the tokenizer and
  DynamicCodeBlock imports to lazy import(); both already had Prism/plaintext
  fallbacks, so highlighting upgrades to Shiki once the async chunk resolves.
  First-load raw JS on those pages drops ~8.5 MB.

- @agentaai/nextstepjs (~136 kB: NextStepReact + motion/spotlight) sat in the
  shared _app chunk on every page though tours only run during onboarding. It
  locates targets via document.querySelector and renders through a portal, so
  it does not need to wrap the app tree: render children directly and mount the
  renderer as a lazy childless sibling via a local split-point module. Mark the
  package sideEffects:false (pnpm patch) so the lightweight NextStepProvider
  context import no longer drags the renderer back into _app via the barrel.
  Shared first-load JS: 1.07 MB -> 1.02 MB; _app 906 kB -> 859 kB.
The Fern-generated root AgentaApiClient statically imports all 27 resource
clients, so every reference to it bundled ~515 kB. It was instantiated at
module scope in _app purely to pin the host, dragging the whole client into
the shared first-load chunk on every page.

Resource clients self-normalize auth in their own constructors (they accept the
same BaseClientOptions as the root client), so they can be constructed directly
without the aggregator. Changes:

- @agentaai/api-client: mark sideEffects:false and expose ./resources/* subpath
  exports so individual resource clients are importable and tree-shakeable.
- @agenta/sdk: add config.ts (host pin via configureAgentaSdk + buildClientOptions,
  no AgentaApiClient import) and resources.ts (per-resource lazy accessors that
  import one resource client each). init/getAgentaSdkClient stay for compatibility.
- _app pins the host via configureAgentaSdk (config-only entry) instead of
  constructing the monolith.
- Migrate the entities api modules (trace, gatewayTool, secret, event, workflow,
  testset) and the evaluations consumers to per-resource accessors.

api-client bytes in the shared _app chunk drop 515 kB -> 233 kB (only the 6
resource clients actually reachable from _app's import graph remain; the other
21 are no longer bundled at all). _app parsed 3.06 MB -> 2.79 MB.
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jun 28, 2026 10:29am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 55c4493a-2f36-4945-8152-4f16d872f14a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fe-chore/bundle-size-optimizations-06-2026

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

2 participants