Skip to content

perf(client): defer workspace and heavy UI bundles - #220

Merged
bbsngg merged 4 commits into
mainfrom
perf/client-ux-loading
Sep 6, 2026
Merged

perf(client): defer workspace and heavy UI bundles#220
bbsngg merged 4 commits into
mainfrom
perf/client-ux-loading

Conversation

@davidliuk

@davidliuk davidliuk commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • defer authenticated workspace providers and routes until setup/login completes
  • lazy-load feature pages, settings, project creation, editor, and syntax highlighting
  • isolate lazy-load failures with localized loading and recovery states
  • update sidebar resizing on animation frames and keep rendered/stored widths consistent
  • make Service Worker registration consistent across production and development
  • prevent pre-auth TaskMaster requests and duplicate 401 console errors

Performance

Measured on production builds of main and this branch (gzip):

main this PR
eagerly loaded before login (entry + modulepreloads) 1180 KB 150 KB
entry chunk alone 810 KB 98 KB
chat chunk 465 KB 154 KB
  • code editor (CodeMirror), terminal (xterm), settings, dashboards and syntax highlighting load only when needed
  • react/jsx-runtime no longer rides along in the CodeMirror vendor chunk, so the login screen no longer preloads CodeMirror

Verification

  • npm run typecheck
  • npm test -- --run — 26 files, 177 tests
  • npm run build
  • desktop and 390px mobile Playwright smoke checks
  • focused code review with no remaining Critical or Important findings
  • git diff --check

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

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)

  1. react/jsx-runtime lands in vendor-codemirror (vite.config.js:102). manualChunks pins 'react', which does not match the react/jsx-runtime subpath, so Rollup put it in the first manual chunk that referenced it. The entry chunk begins with import{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 lazy CodeEditor only defers the small app chunk. Add 'react/jsx-runtime' (and 'react-dom/client') to the vendor-react list. Pre-existing on main, but it directly undercuts this PR's goal.
  2. xterm is on the pre-login path via Onboarding → LoginModal → StandaloneShell → Shell. Confirmed with a sourcemap build: Shell.jsx and StandaloneShell.jsx are inside the entry chunk. lazy()-loading StandaloneShell inside LoginModal.jsx saves 94 KB gzip on the login screen.
  3. LazyLoadBoundary treats every runtime error as a chunk-load failure and drops the details panel (MainContent.tsx:367). It always passes fallback, so ErrorBoundary's showDetails / "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. Since resetKey is 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's Failed to fetch dynamically imported module / ChunkLoadError, and otherwise rendering the old details UI (or nesting <ErrorBoundary showDetails> inside the Suspense).
  4. resetKey={editingFile.path} remounts CodeEditor on every file switch (ChatInterface.tsx:1007). It is applied as React key, whereas before CodeEditor reloaded 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.
  5. PlainCodeBlock padding does not match the highlighted path (Markdown.tsx:24). The language label and copy button are absolutely positioned for 2rem top padding; the fallback uses uniform p-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.
  6. Three-hop serial chunk waterfall after login: App → AuthenticatedWorkspace → AppContent → ChatInterface/ProjectDashboard, each import() starting only after the parent chunk rendered, with three spinner swaps. The AuthenticatedWorkspace split buys nothing since both non-survey routes render AppContent. Import AppContent statically there and kick off the workspace import() when the login form mounts; that also removes the byte-identical WorkspaceLoadingFallback duplicated in App.tsx and AuthenticatedWorkspace.tsx.
  7. Service worker: keeping it registered in production is a behavior change worth a second look. public/sw.js cache.puts navigation responses without a response.ok check, 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 by index.html and immediately unregistered by main.jsx on every load), but this PR makes it stable. Either drop the registration or add the ok-guard.
  8. test/electron-preload.test.mjs never runs anywhere: vitest.config.ts excludes test/**, npm test is vitest run, and no workflow runs node --test. It passes under node --test (2/2) but a revert of the .cjs change 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 / TaskMasterProvider under ProtectedRoute is safe: nothing in SetupForm, LoginForm, Onboarding or their subtrees, nor AuthContext / 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.width prop value is unchanged between renders, and both mouseup and window blur commit and clean up.
  • Preload .mjs → .cjs is correct with sandbox: true, and dropping the synchronous document.documentElement.dataset writes is right (it can be null at preload time and a throw there means electronAPI is never exposed). No CSS or JS on main or on #190 consumes data-platform / data-electron. electron-builder uses electron/**/*, so no packaging change needed. #190 touches createWindow a few lines below preloadPath; 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 bbsngg 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.

Pushed 08b2ae5 on this branch addressing the review, so nothing blocking is left:

  • VersionUpgradeModal is 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 rejected lazy so an error-boundary reset or close/reopen starts a fresh load instead of rethrowing React's cached rejection. Every lazy() site now uses it.
  • Markdown.tsx deep-imports react-syntax-highlighter/dist/esm/prism; the ~1 MB highlight.js + async-loader chunk is gone from the build.
  • LazyLoadBoundary only shows the "reload" copy for chunk-load errors (isChunkLoadError), runtime errors fall through to the old showDetails UI with Try Again, and it no longer uses resetKey as a React key, so switching files in the embedded editor no longer tears CodeEditor down.
  • PlainCodeBlock mirrors the highlighted padding for language-tagged blocks.
  • manualChunks switched to the function form; react/jsx-runtime now lives in vendor-react, and LoginModal lazy-loads StandaloneShell, 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.

@bbsngg
bbsngg merged commit 01ef795 into main Sep 6, 2026
3 checks passed
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