Skip to content

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

Merged
marcusds merged 5 commits into
mainfrom
studio-entry-bundle-split/mschwab
Aug 7, 2026
Merged

marcusds merged 5 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

    • Code editors and compact chat now load on demand, improving initial application load times.
    • Chat resources begin loading when hovering over the chat control for a faster opening experience.
  • Bug Fixes

    • Added clearer loading states for file previews and chat.
    • Copilot chat errors are isolated with a retry option instead of disrupting the surrounding interface.
    • Improved handling of YAML linting and application startup initialization.

@github-actions github-actions Bot added the perf conventional-commit type label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 31261/39870 78.4% 62.8%
Integration Tests 18207/37822 48.1% 20.6%

@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

📝 Walkthrough

Walkthrough

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 loading are deferred. File previews use Suspense and asynchronous tests.
Studio startup synchronization
web/packages/studio/src/main.tsx
Startup waits for telemetry initialization and theme stylesheet loading.
Chat thread error recovery
web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.*
The error boundary logs render errors, displays fallback content, and supports retry.
Chat thread loading and integration
web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx
The chat thread preloads on hover, renders after the popout opens, and uses Suspense with retry handling.

Sequence Diagram(s)

sequenceDiagram
  participant ChatTrigger
  participant CopilotTopBarChat
  participant CopilotChatThread
  participant ChatThreadErrorBoundary
  ChatTrigger->>CopilotTopBarChat: Hover trigger
  CopilotTopBarChat->>CopilotChatThread: Preload module
  ChatTrigger->>CopilotTopBarChat: Open popout
  CopilotTopBarChat->>CopilotChatThread: Render lazily
  CopilotChatThread-->>ChatThreadErrorBoundary: Return content or throw error
  ChatThreadErrorBoundary->>CopilotTopBarChat: Invoke retry callback
  CopilotTopBarChat->>CopilotChatThread: Recreate lazy component
Loading

Possibly related PRs

Suggested reviewers: steramae-nvidia, htolentino-nvidia, aray12

🚥 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 summarizes the main change: code-splitting Studio's entry chunk and reducing its size.
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
Comment thread web/packages/studio/src/main.tsx
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>
Signed-off-by: mschwab <mschwab@nvidia.com>
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>
@marcusds
marcusds enabled auto-merge August 6, 2026 20:58
@marcusds
marcusds force-pushed the studio-entry-bundle-split/mschwab branch from b03a629 to 08431ff Compare August 6, 2026 21:51
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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.

🧹 Nitpick comments (1)
web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx (1)

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

Test rejected lazy-import recovery through the popout.

The boundary tests throw from Boom after module loading. They do not exercise lazy, preloadChatThread, or retryChatThread. Add a CopilotTopBarChat test that rejects the first chat-thread import and resolves a fresh import after Try Again.

🤖 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/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx`
around lines 27 - 33, Add a CopilotTopBarChat test covering rejected lazy-import
recovery through the popout: mock importChatThread so its first call rejects,
render the popout, trigger Try Again via retryChatThread, and verify a fresh
import resolves and the chat thread renders. Exercise createChatThread and
preloadChatThread rather than only testing the Boom boundary.
🤖 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.

Nitpick comments:
In
`@web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx`:
- Around line 27-33: Add a CopilotTopBarChat test covering rejected lazy-import
recovery through the popout: mock importChatThread so its first call rejects,
render the popout, trigger Try Again via retryChatThread, and verify a fresh
import resolves and the chat thread renders. Exercise createChatThread and
preloadChatThread rather than only testing the Boom boundary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: b761b2e8-278b-4c22-8ffe-cdb9edfe5b39

📥 Commits

Reviewing files that changed from the base of the PR and between c71ca67 and 08431ff.

📒 Files selected for processing (8)
  • 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/CopilotChatRoute/ChatThreadErrorBoundary.test.tsx
  • web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.tsx
  • web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • web/packages/common/src/components/CodeEditor/constants.ts
  • web/packages/studio/src/main.tsx
  • web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx
  • web/packages/common/src/components/FileContentPreview/index.tsx
  • web/packages/common/src/components/CodeEditor/linters/yaml.ts

@marcusds
marcusds added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 6bfcaed Aug 7, 2026
52 checks passed
@marcusds
marcusds deleted the studio-entry-bundle-split/mschwab branch August 7, 2026 00:51
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