perf(client): defer workspace and heavy UI bundles - #220
Conversation
bbsngg
left a comment
There was a problem hiding this comment.
Read the whole diff and verified it locally in a clean worktree: npm run typecheck clean, vitest run 24 files / 163 tests green, npm run build clean. The direction is right and the provider move is safe (see confirmations at the bottom). Four items below are introduced by this PR and I'd like them fixed before merge; the rest can be follow-ups.
1. The bundle numbers only count the entry chunk
The "2.85 MB / 836 KB gzip → 333 KB / 108 KB gzip" figure is the entry chunk alone. dist/index.html still modulepreloads three vendor chunks that the browser fetches before the entry executes. I built main and this branch side by side:
| Eagerly loaded before login (gzip) | main | this PR |
|---|---|---|
entry index-*.js |
810 KB | 108 KB |
vendor-codemirror |
225 KB | 225 KB |
vendor-xterm |
94 KB | 94 KB |
vendor-react |
51 KB | 51 KB |
| total | 1180 KB | 478 KB |
That is a ~60% cut, which is still excellent, but the description should state the real number. Both vendor chunks are on the pre-login path for identifiable reasons, and both fixes are a few lines (see follow-ups 1 and 2).
2. Blocking: VersionUpgradeModal now unmounts on close, losing in-flight update state (SidebarModals.tsx:185)
Before, the modal was always mounted and returned null when closed, so isUpdating / updateOutput survived a dismiss and "Update Now" stayed hidden. Now {showVersionModal && ...} unmounts it. Sequence: user clicks Update Now (server runs git stash && git checkout main && git pull && npm install, tens of seconds) → dismisses the modal → result is lost → the sidebar badge is still visible → reopening mounts a fresh instance with Update Now enabled → a second POST /api/system/update races the first. server/index.js has no in-progress guard on that route. Either keep it mounted as before, or lift the update state out of the modal.
3. Blocking: React.lazy caches a rejected import, so Close → reopen never recovers (LazyLoadBoundary.tsx)
Every lazy component in this PR is created once at module scope (Settings, ProjectCreationWizard, CodeEditor, the eight in MainContent.tsx). React 18's lazyInitializer moves the payload to Rejected and rethrows forever after. So after one transient chunk failure while opening Settings, closing and reopening remounts a fresh boundary but the same lazy component throws the cached error again; the only working button is Reload, which discards open chat tabs and drafts to recover a single modal. Vite's __vitePreload does not retry either (it only dispatches vite:preloadError, which nothing listens for). A lazyWithRetry(factory) wrapper, or re-creating the lazy component when the boundary resets, would make the "recover from chunk load failures" commit actually hold.
4. Blocking: lazy-importing the react-syntax-highlighter package root is heavier than main's static import (Markdown.tsx:21)
import('react-syntax-highlighter') compiles to a shared chunk (index-CWRjc_yV.js in my build) that is 988 KB raw / 313 KB gzip: 192 highlight.js language files, lowlight, the Light / LightAsync / PrismAsync loaders and 469 dynamic per-language import() entries Rollup cannot drop. It then statically imports the 638 KB Prism chunk, which is all this code uses. On main the static import { Prism } was tree-shaken to the Prism chunk only. Net effect: the first assistant message with a fenced code block downloads ~1.6 MB raw (~540 KB gzip), about 1 MB of which never executes.
| First code block render (gzip) | main | this PR |
|---|---|---|
| Prism chunk | 223 KB | 223 KB |
| highlight.js + loaders shared chunk | 0 | 313 KB |
Fix: import('react-syntax-highlighter/dist/esm/prism') (the file already deep-imports dist/esm/styles/prism), or prism-light + registerLanguage.
Follow-ups (non-blocking, but 1 and 2 are cheap enough to include here)
react/jsx-runtimelands invendor-codemirror(vite.config.js:102).manualChunkspins'react', which does not match thereact/jsx-runtimesubpath, so Rollup put it in the first manual chunk that referenced it. The entry chunk begins withimport{j as i}from"./vendor-codemirror-*.js"and that is the only binding it takes from there. Every visitor downloads 225 KB gzip of CodeMirror to get the JSX runtime, which also means the lazyCodeEditoronly defers the small app chunk. Add'react/jsx-runtime'(and'react-dom/client') to thevendor-reactlist. Pre-existing on main, but it directly undercuts this PR's goal.- xterm is on the pre-login path via
Onboarding → LoginModal → StandaloneShell → Shell. Confirmed with a sourcemap build:Shell.jsxandStandaloneShell.jsxare inside the entry chunk.lazy()-loadingStandaloneShellinsideLoginModal.jsxsaves 94 KB gzip on the login screen. LazyLoadBoundarytreats every runtime error as a chunk-load failure and drops the details panel (MainContent.tsx:367). It always passesfallback, soErrorBoundary'sshowDetails/ "Try Again" UI is unreachable and now has no caller. A renderer throwing on a malformed tool result mid-session shows "This part of Dr. Claw could not be loaded… The app may have been updated" with a full-page reload as the only exit, and the component stack people paste into bug reports is gone. SinceresetKeyis the constant'chat'and the chat container is only hidden by class, switching sessions never resets it either. Suggest only using the lazyLoad copy when the error matches Vite'sFailed to fetch dynamically imported module/ChunkLoadError, and otherwise rendering the old details UI (or nesting<ErrorBoundary showDetails>inside the Suspense).resetKey={editingFile.path}remountsCodeEditoron every file switch (ChatInterface.tsx:1007). It is applied as Reactkey, whereas beforeCodeEditorreloaded in place via its[file?.path, file?.name]effect. Switching from file A to file B while fullscreen now drops fullscreen, diff view and overlay state.PlainCodeBlockpadding does not match the highlighted path (Markdown.tsx:24). The language label and copy button are absolutely positioned for2remtop padding; the fallback uses uniformp-4, so during load (and permanently on failure) they overlap the first line, and every code block jumps 1rem when the chunk arrives.language && language !== 'text' ? 'pt-8 px-4 pb-4' : 'p-4'fixes it.- Three-hop serial chunk waterfall after login:
App → AuthenticatedWorkspace → AppContent → ChatInterface/ProjectDashboard, eachimport()starting only after the parent chunk rendered, with three spinner swaps. TheAuthenticatedWorkspacesplit buys nothing since both non-survey routes renderAppContent. ImportAppContentstatically there and kick off the workspaceimport()when the login form mounts; that also removes the byte-identicalWorkspaceLoadingFallbackduplicated inApp.tsxandAuthenticatedWorkspace.tsx. - Service worker: keeping it registered in production is a behavior change worth a second look.
public/sw.jscache.puts navigation responses without aresponse.okcheck, so a proxy 502 during a server restart gets cached under/and is served on the next network blip. It never caches hashed/assets/*.js, so the "retaining offline support" comment does not hold, and every/api/*GET pays the SW hop for nothing. Pre-existing on main (where the SW was registered byindex.htmland immediately unregistered bymain.jsxon every load), but this PR makes it stable. Either drop the registration or add the ok-guard. test/electron-preload.test.mjsnever runs anywhere:vitest.config.tsexcludestest/**,npm testisvitest run, and no workflow runsnode --test. It passes undernode --test(2/2) but a revert of the.cjschange would keep CI green. Follows the existing convention for that directory, so not introduced here, but the "24 files, 163 tests" count does not include it.
Confirmed fine
- Moving
WebSocketProvider/TasksSettingsProvider/TaskMasterProviderunderProtectedRouteis safe: nothing inSetupForm,LoginForm,Onboardingor their subtrees, norAuthContext/ThemeContext, consumes those contexts. The WebSocket already required a token, so this does remove the pre-auth 401s. - Sidebar resize via rAF + direct DOM write: a React re-render mid-drag will not snap the width back because the
style.widthprop value is unchanged between renders, and bothmouseupandwindowblurcommit and clean up. - Preload
.mjs → .cjsis correct withsandbox: true, and dropping the synchronousdocument.documentElement.datasetwrites is right (it can be null at preload time and a throw there meanselectronAPIis never exposed). No CSS or JS onmainor on #190 consumesdata-platform/data-electron.electron-builderuseselectron/**/*, so no packaging change needed. #190 touchescreateWindowa few lines belowpreloadPath; expect a trivial rebase.
Fixes from the review of #220, all verified with typecheck, the full vitest suite (26 files / 177 tests) and a production build: - Keep VersionUpgradeModal mounted (static import, as before). It owns the in-flight update output; unmounting it on close lost that output and let a reopened instance start a second concurrent /api/system/update. - Add lazyWithRetry: React.lazy caches a rejected import forever, so one transient chunk failure left a modal/panel broken until a full reload. The wrapper retries transient failures and discards the rejected lazy so an error-boundary reset or close/reopen starts a fresh load. All lazy() sites now use it. - Deep-import react-syntax-highlighter/dist/esm/prism in Markdown. The package root also bundled highlight.js and the async language loaders (~1 MB raw / 313 KB gzip) that were never executed. - LazyLoadBoundary only shows the "reload to fetch the latest version" copy for chunk-load errors; runtime errors fall through to ErrorBoundary's default UI with the stack trace and in-place Try Again. It no longer uses resetKey as a React key, so the embedded CodeEditor is not torn down when switching files. - PlainCodeBlock mirrors the highlighted block's padding so the language label and copy button never overlap the first line and nothing shifts when the highlighter chunk arrives. - manualChunks switched to the function form. The object form also captured each listed package's transitive dependencies, which put react/jsx-runtime into vendor-codemirror and made every page load (including the login screen) modulepreload all of CodeMirror. - LoginModal lazy-loads StandaloneShell so xterm stays off the pre-login entry chunk. Eagerly loaded before login (gzip): main 1180 KB -> PR 478 KB -> now 150 KB (entry 98 KB + vendor-react 51 KB). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6f3rjUgLJcMnsdbSS6d8C
bbsngg
left a comment
There was a problem hiding this comment.
Pushed 08b2ae5 on this branch addressing the review, so nothing blocking is left:
VersionUpgradeModalis a static import again and stays mounted while closed, restoring the pre-PR behaviour that kept the in-flight update output and prevented a second concurrent update.- New
src/utils/lazyWithRetry.tsx: retries transient chunk failures and discards the rejectedlazyso an error-boundary reset or close/reopen starts a fresh load instead of rethrowing React's cached rejection. Everylazy()site now uses it. Markdown.tsxdeep-importsreact-syntax-highlighter/dist/esm/prism; the ~1 MB highlight.js + async-loader chunk is gone from the build.LazyLoadBoundaryonly shows the "reload" copy for chunk-load errors (isChunkLoadError), runtime errors fall through to the oldshowDetailsUI with Try Again, and it no longer usesresetKeyas a Reactkey, so switching files in the embedded editor no longer tearsCodeEditordown.PlainCodeBlockmirrors the highlighted padding for language-tagged blocks.manualChunksswitched to the function form;react/jsx-runtimenow lives invendor-react, andLoginModallazy-loadsStandaloneShell, so the login screen no longer preloads CodeMirror or xterm.
Eagerly loaded before login (gzip): main 1180 KB → PR as opened 478 KB → now 150 KB (entry 98 KB + vendor-react 51 KB). PR description updated with the measured numbers.
Local: typecheck clean, vitest 26 files / 177 tests (14 new, covering the retry loop and the chunk-error classifier), npm run build clean, git diff --check clean.
Left as follow-ups (not introduced here): the post-login chunk waterfall, the service worker response.ok guard, and wiring test/ into CI. @davidliuk feel free to amend if you'd rather structure any of it differently.
Summary
Performance
Measured on production builds of
mainand this branch (gzip):modulepreloads)react/jsx-runtimeno longer rides along in the CodeMirror vendor chunk, so the login screen no longer preloads CodeMirrorVerification
npm run typechecknpm test -- --run— 26 files, 177 testsnpm run buildgit diff --check