Skip to content

perf(studio): code-split the entry chunk (1,641 kB -> 326 kB) - #1100

Open
marcusds wants to merge 4 commits into
mainfrom
studio-entry-bundle-split/mschwab
Open

perf(studio): code-split the entry chunk (1,641 kB -> 326 kB)#1100
marcusds wants to merge 4 commits into
mainfrom
studio-entry-bundle-split/mschwab

Conversation

@marcusds

@marcusds marcusds commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Studio's entry chunk had grown to 1,641 kB (gzip 535 kB). This splits it down to 326 kB (gzip 76 kB) — an 80% reduction in raw bytes, 86% gzipped.

chunk before after
index-*.js 1,641.04 kB / gzip 535.38 kB 326.38 kB / gzip 75.84 kB

Why it was big

Two static imports in the app shell — which renders on every route — were pulling the entire NeMo Copilot chat surface into the entry chunk:

GlobalNav
  -> ClaudeCodeTopBarChat
    -> ClaudeCodeChatThread
      -> @assistant-ui/*, remark-gfm -> micromark stack
      -> ClaudeCodeToolCallPart -> Chat/MessageContent -> MarkdownDataViewTable
           -> DataView/internal -> @tanstack/table-core, @dnd-kit/core, date-fns
      -> BlockingInputComposer -> AgentBlockingInput
           -> DatasetFileSelect -> FileContentPreview -> CodeEditor
                -> @codemirror/*, @lezer/{common,javascript,python}, yaml, papaparse

ClaudeCodeChatRoute was already lazy(), but that made no difference — everything it needed had already been hoisted into the entry chunk by the nav's static import. The route chunk was 2.2 kB.

Separately, main.tsx imported @studio/telemetry/telemetry as a top-level side effect, putting the OpenTelemetry SDK + zone.js (~162 kB) on the critical path.

Changes

ClaudeCodeTopBarChat.tsxlazy() the chat thread, gated on a hasOpened latch so the chunk fetches on first pop-out open and stays mounted after (matching the previous always-mounted behaviour). The trigger button and its thinking/unread badges stay synchronous. Warmed on hover so the first open is instant.

FileContentPreviewlazy() the CodeEditor behind a Suspense spinner. Moves 602 kB off the boot path.

CodeEditor/constants.tsimport type { BasicSetupOptions }. It was a value import, so importing ContentType alone still dragged in @uiw/react-codemirror; without this the split above wouldn't hold.

CodeEditor/linters/yaml.tsawait import('yaml') inside the lint source (linter accepts an async LintSource). Splits 103 kB out of the editor chunk into its own.

main.tsx — telemetry moved from a top-level side-effect import to a fired-not-awaited import(), awaited in Promise.all alongside waitForThemeStylesheet() before root.render. OTel still patches fetch/XHR before the first app request; the 140 kB now downloads in parallel instead of inflating the entry chunk.

Resulting chunk layout

index-*.js                326.38 kB │ gzip:  75.84 kB   (was 1,641.04 / 535.38)
CodeEditor-*.js           602.50 kB │ gzip: 209.00 kB   lazy, on file preview
telemetry-*.js            140.22 kB │ gzip:  43.18 kB   parallel, pre-render
browser-*.js (yaml)       103.05 kB │ gzip:  31.54 kB   lazy, on first YAML lint
ClaudeCodeChatThread-*.js  32.45 kB │ gzip:   9.83 kB   lazy, on first chat open

Measured load impact

Built main at the branch point and this PR head, served each dist from a local static server, loaded cold-cache in headless Chromium. Median of 9 runs after a discarded warm-up; backend requests aborted so both variants see identical (zero) API latency.

Desktop, no throttling

before after Δ
FCP 124 ms 96 ms −23%
LCP 124 ms 96 ms −23%
DOMContentLoaded 105 ms 64 ms −39%
load 106 ms 65 ms −39%
ScriptDuration 87 ms 81 ms −6%
JS files before load 48 23 −52%
JS bytes before load 4,457 kB 3,008 kB −1,449 kB

Throttled — 4× CPU, 1.6 Mbps / 150 ms RTT

before after Δ
FCP 25,628 ms 18,956 ms −6.7 s (−26%)
LCP 25,628 ms 18,956 ms −26%
DOMContentLoaded 25,568 ms 17,978 ms −30%
load 25,570 ms 17,980 ms −30%
ScriptDuration 289 ms 273 ms −6%
Long-task time 182 ms 109 ms −40%
JS bytes before load 4,457 kB 3,008 kB −1,449 kB

The 1,449 kB drop in critical-path JS is the whole story. Script execution barely moves, which is expected — the deferred code was never executing at boot, it was being downloaded and parsed.

Caveat: the absolute throttled numbers are inflated — the measurement server is HTTP/1.1, so every request pays the full 150 ms RTT. The relative delta is the trustworthy part. Note that the uncompressed bytes may not be an artifact: see next steps, I could not find compression on the Studio static mount either.

Netting out the shared vendor bundles (2,379 kB, byte-identical in both builds), Studio's own critical-path JS goes 1,973 kB → 558 kB (−72%).

Recommended next steps

After this PR the critical path is 24 resources / 3,488 kB uncompressed, broken down as:

resource bytes share
vendor/foundations.js 1,996 kB 57%
index.css 480 kB 14%
index.js (entry) 327 kB 9%
vendor/react-router.js 200 kB 6%
vendor/react-dom.js 185 kB 5%
oidc-client-ts.js 108 kB 3%
zod / react-query / axios 151 kB 4%
15 smaller chunks ~41 kB 1%

Ordered by measured impact:

1. Serve Studio's assets compressed. I could not find any compression on the static mount — no GZipMiddleware (or equivalent) in services/studio/src/nmp/studio/service.py, which mounts SPAStaticFiles (a plain Starlette StaticFiles subclass), and no gzip/brotli configuration in k8s/helm. If that's accurate for deployed environments too, the whole 3,488 kB critical path is going over the wire uncompressed. The chunks gzip at roughly 4:1 (the entry chunk is 326 kB → 76 kB), so this is on the order of a ~2.5 MB reduction for a few lines of middleware — a bigger win than this entire PR, and much cheaper. Worth confirming against whatever ingress fronts a real deployment before acting.

2. vendor/foundations.js — 1,996 kB, 57% of what's left. The vendor shim in vite.config.ts is a blanket export * from '@nvidia/foundations-react-core' built with codeSplitting: false, so the entire design system ships regardless of how much of it Studio actually renders, and nothing tree-shakes. The blanket re-export exists for a good reason — the bundle has to satisfy the union of what Studio and every runtime-loaded plugin imports, and there must be exactly one instance. But that union could be generated from the components actually imported across packages/studio, packages/common and plugins/*/web rather than assumed to be everything. Biggest single lever left.

3. index.css — 480 kB, render-blocking. Emitted by the Tailwind build. Worth checking whether the content globs are over-broad (the postcss config points at all of packages/) and whether KUI's full stylesheet is being inlined alongside Studio's own utilities.

4. The generated SDK barrel — 142 kB, 44% of the remaining entry chunk. packages/sdk/generated/platform/api.ts is a single orval-generated barrel pulled in eagerly. Per-tag entry points would let each route import only the operations it uses.

5. oidc-client-ts — 108 kB, modulepreloaded in index.html. Eager at boot. Worth checking whether it's genuinely needed before first paint or only on the auth/silent-renew path.

6. ClaudeCodeChatProvider — ~90 kB, mostly @assistant-ui/core. Still eager in PageLayout; see the note below on why it wasn't done here.

7. Add a size budget to CI. build.chunkSizeWarningLimit only warns and is easy to ignore. An assertion on the entry chunk's gzip size would stop this regressing back to 535 kB the next time something is imported into the app shell — which is exactly how it got there.

Notes

  • The second commit is a pass with the Vercel React best-practices rules. Worth calling out one finding: KUI's PopoverTrigger spreads ...props after its own onPointerEnter, so passing onPointerEnter (or onFocus) to a Popover trigger silently replaces the trigger's handler and breaks opening the pop-out. The hover preload uses onMouseEnter for that reason — there are unit tests covering it.
  • Not addressed: ClaudeCodeChatProvider is still eager in PageLayout (~90 kB, mostly @assistant-ui/core). Every no-remount way to defer it requires making ClaudeCodeChatContextValue nullable — useClaudeCodeChatContext currently throws on null. That's a design change, not a perf tweak, so it's left out.
  • Everything else that turned up while measuring is in Recommended next steps above; none of it is in scope here.

Testing

  • tsc --noEmit clean
  • @nemo/common: 1379/1379 pass (four FileContentPreview assertions moved to findByTestId for the now-suspended editor)
  • nemo-studio-ui: full suite green except SafeSynthesizerNewRoute, which times out at 10 s under full-suite contention and passes on its own — unrelated to these files
  • eslint + prettier clean
  • Headless load of the production build: app renders, no module errors, telemetry-*.js loads, and neither CodeEditor-*.js nor ClaudeCodeChatThread-*.js is requested at boot

Summary by CodeRabbit

  • Performance

    • Improved application startup and loading efficiency.
    • File previews now load the code editor on demand, with a spinner displayed while loading.
    • Chat threads load when opened, while hovering prepares them for faster access.
  • Bug Fixes

    • Improved handling and display of YAML parsing diagnostics.
    • Updated preview behavior to reliably support JSON, JSONL, nested paths, and plain-text files.
    • Added retry support when chat threads fail to load.

The Studio entry chunk had grown to 1,641 kB (gzip 535 kB) because the
app shell statically imported subtrees that pulled in heavy libraries on
every route.

GlobalNav statically imported ClaudeCodeTopBarChat, which reached
ClaudeCodeChatThread and through it assistant-ui, the remark/micromark
markdown stack, DataView + table-core + dnd-kit + date-fns, and — via
AgentBlockingInput -> DatasetFileSelect -> FileContentPreview ->
CodeEditor — all of CodeMirror, the lezer grammars, yaml and papaparse.
None of it is needed until the copilot pop-out is opened.

- ClaudeCodeTopBarChat: lazy() the chat thread, gated on a hasOpened
  latch so the chunk loads on first open and stays mounted afterwards.
  The trigger button and its thinking/unread badges stay synchronous.
- FileContentPreview: lazy() the CodeEditor behind a Suspense spinner.
- CodeEditor/constants: import BasicSetupOptions as a type, so importing
  ContentType alone no longer drags in @uiw/react-codemirror.
- CodeEditor yaml linter: await import('yaml') inside the lint source.
- main.tsx: start the telemetry import without awaiting it, then await
  it alongside the theme stylesheet before rendering. OpenTelemetry
  still patches fetch/XHR before the first request, but the OTel SDK no
  longer sits in the entry chunk.

Entry chunk is now 326 kB (gzip 76 kB), an 80% reduction.

Signed-off-by: mschwab <mschwab@nvidia.com>
Follow-up from a pass with the Vercel React best-practices rules.

- bundle-preload: warm the chat chunk on hover of the top-bar trigger so
  the first open is instant instead of waiting on a ~400 kB fetch. Uses
  onMouseEnter, not onPointerEnter/onFocus — KUI's PopoverTrigger spreads
  `...props` after its own handlers, so either of those replaces the
  trigger's and breaks opening the pop-out.
- rendering-hoist-jsx: hoist the Suspense fallback element to module
  scope instead of rebuilding it on every render.

Signed-off-by: mschwab <mschwab@nvidia.com>
@github-actions github-actions Bot added the perf conventional-commit type label Aug 5, 2026
Signed-off-by: mschwab <mschwab@nvidia.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 30743/39237 78.3% 62.7%
Integration Tests 18062/37189 48.6% 21.0%

@marcusds
marcusds marked this pull request as ready for review August 5, 2026 19:16
@marcusds
marcusds requested review from a team as code owners August 5, 2026 19:16
@marcusds
marcusds added this pull request to the merge queue Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6ef17879-88da-4b5c-94c3-fb945c5b0180

📥 Commits

Reviewing files that changed from the base of the PR and between aaf7d48 and b03a629.

📒 Files selected for processing (5)
  • web/packages/common/src/components/FileContentPreview/index.tsx
  • web/packages/studio/src/main.tsx
  • web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ChatThreadErrorBoundary.test.tsx
  • web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ChatThreadErrorBoundary.tsx
  • web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • web/packages/common/src/components/FileContentPreview/index.tsx
  • web/packages/studio/src/main.tsx

📝 Walkthrough

Walkthrough

The changes defer CodeEditor, YAML, telemetry, and Claude chat-thread loading. File previews and chat rendering use Suspense fallbacks. Studio startup waits for telemetry and theme initialization. Chat-thread rendering adds retry support.

Changes

Runtime loading changes

Layer / File(s) Summary
CodeEditor and preview loading
web/packages/common/src/components/CodeEditor/..., web/packages/common/src/components/FileContentPreview/...
CodeEditor and YAML imports load asynchronously. JSON and plain-text previews use Suspense with a spinner. Tests await the lazy editor.
Studio startup synchronization
web/packages/studio/src/main.tsx
Telemetry loads through a promise. Startup waits for telemetry initialization and the theme stylesheet before mounting React.
Chat thread loading and recovery
web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/...
The chat thread loads after the popout opens, preloads when the trigger is hovered, shows a spinner while loading, and supports retry after render errors.

Possibly related PRs

Suggested reviewers: steramae-nvidia, htolentino-nvidia

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: code-splitting Studio's entry chunk and its measured size reduction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch studio-entry-bundle-split/mschwab

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
web/packages/common/src/components/FileContentPreview/index.tsx (1)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import FC as a type.

FC is used only in the FileContentPreview type annotation. Keep it out of the runtime React import.

Proposed change
-import { FC, lazy, Suspense, useEffect, useMemo, useState } from 'react';
+import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
+import type { FC } from 'react';

As per coding guidelines, use import type for type-only imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/packages/common/src/components/FileContentPreview/index.tsx` at line 15,
Update the React imports in FileContentPreview to import FC with a type-only
import while keeping lazy, Suspense, useEffect, useMemo, and useState in the
runtime import.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/packages/studio/src/main.tsx`:
- Line 12: Update the startup flow around telemetryReady so failures from the
telemetry import or module initialization are caught before Promise.all can
reject. Allow the application to mount without telemetry when it is optional, or
render the established explicit startup error when telemetry is required, while
preserving normal mounting on successful initialization.

In
`@web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx`:
- Around line 29-33: Update preloadChatThread to locally catch and handle
importChatThread() failures instead of discarding the rejected promise. Also
ensure ClaudeCodeChatThread’s lazy import rejection is handled outside Suspense,
either with an appropriate error boundary or a retry/error state when the user
opens the chat.

---

Nitpick comments:
In `@web/packages/common/src/components/FileContentPreview/index.tsx`:
- Line 15: Update the React imports in FileContentPreview to import FC with a
type-only import while keeping lazy, Suspense, useEffect, useMemo, and useState
in the runtime import.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c38f00e7-8e74-4b28-a703-46af85596228

📥 Commits

Reviewing files that changed from the base of the PR and between 071c81c and aaf7d48.

📒 Files selected for processing (6)
  • web/packages/common/src/components/CodeEditor/constants.ts
  • web/packages/common/src/components/CodeEditor/linters/yaml.ts
  • web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx
  • web/packages/common/src/components/FileContentPreview/index.tsx
  • web/packages/studio/src/main.tsx
  • web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx

Comment thread web/packages/studio/src/main.tsx Outdated
@marcusds
marcusds removed this pull request from the merge queue due to a manual request Aug 5, 2026
const rootElement = document.getElementById('app')!;
if (!rootElement.innerHTML) {
waitForThemeStylesheet().then(() => {
Promise.all([waitForThemeStylesheet(), telemetryReady]).then(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I understand this reduces the initial bundle size but doesn't it just create a synchronous roundtrip that is required before first render?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah but it's happening when we are already waiting for stylesheets so I don't think there would be much or any negative impact.

Address review on the entry-chunk code split:

- main.tsx: telemetry is optional, so catch its dynamic import. Without a
  handler a failed telemetry chunk rejects the Promise.all and React never
  mounts, leaving a blank page.
- ClaudeCodeTopBarChat: catch the hover preload rejection, and wrap the lazy
  chat thread in an error boundary. Nothing above GlobalNav catches, so a
  failed chunk fetch unwound to the root and blanked all of Studio. Retry
  builds a fresh lazy component since React caches a rejected import.
- FileContentPreview: import FC as a type.

Signed-off-by: mschwab <mschwab@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf conventional-commit type

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants