Skip to content

perf(bundle): drop axios, split vendors per entry, unify on one virtualizer - #1856

Merged
Harry19081 merged 1 commit into
developfrom
perf/bundle-vendor-split-single-virtualizer
Sep 16, 2026
Merged

Harry19081 merged 1 commit into
developfrom
perf/bundle-vendor-split-single-virtualizer

Conversation

@FresnelR27

Copy link
Copy Markdown
Collaborator

Problem

Three separate sources of avoidable frontend weight, all measured from a
production pnpm build:stats on develop:

  1. The desktop shell paid for the Mobile Remote's dependencies. main
    (src/index.tsx, the Tauri desktop shell) and mobile
    (src/mobileRemoteEntry.tsx, the browser Web Remote) are separate HTML
    documents that never load each other's code, but splitChunks put their
    node_modules into one shared vendors chunk. Because mobileRemoteEntry.tsx
    statically imports Supabase, react-markdown/remark-gfm and its own icon
    set, the desktop boot bundle downloaded and parsed all of it — 744 KB of
    Supabase, the micromark stack and 123 KB of glyphs it never uses.

  2. axios shipped for nothing. It was imported by exactly one file,
    src/util/monitoring/apiTrackerHttp.ts, and only to register interceptors on
    the global axios instance. Nothing in the app ever issued an axios request:
    the apiConfig.ts its comment referenced does not exist, src/api/http/
    uses fetch, and installFetchTracking() in that same file already
    instruments real traffic. 49 KB of boot JS for zero behaviour.

  3. Two virtualization libraries. @tanstack/react-virtual (18 KB) drove
    chat history, Gantt, Kanban, the Spotlight pickers and the PR/issue lists,
    while react-virtuoso (58 KB) drove the file trees, diff lists, DevTools
    console and Project Manager sidebars — with each call site re-deriving its
    own scroll handling.

This also resolves the open question behind the red nightly bundle-budget gate:
the budget has been over since the mobile entry landed, pending a decision on
whether that entry should share the desktop vendors chunk. It should not.

Solution

Per-entry vendor chunks. initialVendors now names its chunk after the
entries a module actually belongs to, yielding vendors.main, vendors.mobile
and vendors.main-mobile. Genuinely shared packages (React, Jotai, i18next) are
still emitted once and shared; single-entry packages stay out of the other
document. No module is duplicated — total emitted JS is unchanged at 27.9 MB,
the bytes just stopped being loaded by the wrong page.

axios removed from package.json, the lockfile, and the interceptor half
of apiTrackerHttp.ts. The now-dead pre-capture path it existed for
(captureApiCallStack, its pending-info map, clearPendingHttpTrackingState,
the axios-XHR de-duplication guard) went with it, along with the axios-internal
frame names in the stack filter. Fetch and XHR tracking are untouched.

One virtualizer. New src/components/VirtualList wraps
@tanstack/react-virtual with the prop surface the react-virtuoso call sites
used (data/totalCount, itemContent, computeItemKey), plus sticky group
headers, a footer slot and a one-shot endReached. Two conversions are handled
centrally because they are easy to get wrong per-site: Virtuoso buffers in
pixels where TanStack buffers in rows, and Virtuoso measures every row where
TanStack measures only rows attached to measureElement.

Migrated onto it: the four shared components that fan out to ~20 surfaces
(VirtualizedStickyTree, TreeRow/VirtualizedListBase, DiffSectionList,
VirtualizedGroupedList) and seven plain lists. The custom Virtuoso Scroller
components those shared trees used only to reach the scroll element and its
onScroll are gone — VirtualList exposes both directly. DiffSectionList
now owns its scroll snapshot type instead of persisting Virtuoso's
StateSnapshot; that state is in-memory only, so no migration is needed.

react-virtuoso remains a dependency for features/CodeViewer
(ModernCodeViewer, VirtualizedModernDiff), which are reached only through
import(). Those are the chat pane's code-block and diff renderers, deliberately
left for a follow-up so this PR does not put that path at risk. A
startupGraph.test.ts entry now pins react-virtuoso out of the boot graph.

Measured result:

before after
Desktop initial JS 1,558 KB 1,024 KB (−34%)
Desktop boot JS 6.88 MiB 6.26 MiB (−635 KB)
Mobile initial JS 2,158 KB 2,137 KB
check:bundle-budget JS 6.52 MB / over 6.26 MB / passes

config/bundle-budget.json maxJsBytes is ratcheted 6,700,000 → 6,600,000 per
that file's own rule.

Potential risks

  • Scroll behaviour is the real risk surface, and it is not unit-testable.
    The file tree, Source Control, Search results, the diff list and the DevTools
    console all depend on scroll-position preservation across tab switches, tree
    expansion and remounts. That logic was reimplemented on VirtualList
    (anchor-by-path restore, mount-time offset restore, reset-to-zero recovery)
    and is exercised by the existing suites, but it has not been driven in the
    running app
    . Reviewers should open My Station, scroll a large file tree,
    switch tabs and come back, and expand/collapse folders.
  • Sticky group headers in VirtualizedGroupedList are now rendered by
    VirtualList's stickyIndices + rangeExtractor rather than by
    GroupedVirtuoso. Covered by tests, but visually unverified.
  • VirtualizedStickyTree lost its rangeChanged callback. It was
    documented as a backup anchor update; the throttled scroll handler is the
    primary one and covers every viewport move. If an anchor is ever missed after
    a non-scroll viewport change, this is the place to look.
  • Two public APIs changed. VirtualizedStickyTree's virtuosoRef prop is
    now listRef: RefObject<VirtualListHandle>, and
    VirtualizedListBaseHandle.getVirtuosoRef() is removed — it had no callers
    and returned a type from the library being dropped.
  • overscan semantics. VirtualizedListBase documented overscan as a
    row count while Virtuoso read it as pixels; it is now honoured as rows
    (overscan * itemHeight px), so those lists render a larger buffer than
    before. No consumer overrides the default.
  • No dependency was added. @tanstack/react-virtual was already a direct
    dependency; axios and its transitive tree (77 lockfile lines) were removed.
    Rollback is a revert — no data, schema or wire format is touched.
  • Unrelated: this branch does not include the two currently-failing
    useChatGroupsProjection tests on develop; they come from in-flight
    empty-turn-grouping work in the shared checkout, not from this change.

Verification

Ran:

  • pnpm build:stats + pnpm check:bundle-budget — boot JS 6.26 MB against the
    ratcheted 6.29 MB budget, passes (it was over before this change).
  • npx tsc --noEmit — clean, 0 errors. Needs
    NODE_OPTIONS=--max-old-space-size=8192; it OOMs at the default heap.
  • pnpm lint:fast (oxlint) — clean.
  • npx eslint over every changed directory with --max-warnings 0 — clean.
    This also fixed a pre-existing react-hooks/refs violation in
    VirtualizedListBase (refs written during render) by moving those writes
    into a layout effect.
  • npx vitest run src/components src/modules src/engines/ChatPanel src/features/CodeViewer src/scaffold — 1,101 files / 6,657 tests pass.
    The only 2 failures are the pre-existing useChatGroupsProjection cases
    described above.
  • src/app/root/__tests__/startupGraph.test.ts — passes with the new
    react-virtuoso guard, confirming it is no longer statically reachable from
    boot. The emitted async-vendors.react-virtuoso.js is now a lazy chunk.

New tests: src/components/VirtualList/index.test.ts (7 cases — windowing, the
pixel→row overscan conversion, one-shot endReached, sticky-header pinning,
findStickyIndex). VirtualizedGroupedList.test.ts was rewritten off
VirtuosoMockContext onto offsetHeight/ResizeObserver stubs.
VirtualizedListBase.test.ts was deleted: its only subject,
resolveInitialTopMostItemIndex, was a workaround for Virtuoso normalizing
initialTopMostItemIndex, and no longer exists.

Did not run: the app itself. Per the risks above, the scroll-preservation
behaviour in My Station, Project Manager and the chat Changes/Timeline views
has not been exercised in the running Tauri app, and there are no screenshots.
Also not run: Playwright E2E, cargo test (no Rust changes).

…alizer

The desktop shell shared a single `vendors` chunk with the browser Mobile
Remote entry, so it downloaded and parsed Supabase, the react-markdown stack
and the Mobile Remote's icon set that only `mobileRemoteEntry.tsx` imports.
Naming the initial vendor chunk by the entries a module belongs to keeps each
document to its own dependencies.

`axios` was imported by exactly one file, and only to register interceptors on
the global instance — nothing in the app ever issued an axios request, and
`installFetchTracking()` already covers real traffic.

The app also carried two virtualization libraries. Every windowed list except
the CodeViewer diff/code panes now runs on `@tanstack/react-virtual` through a
new shared `VirtualList` primitive, which takes react-virtuoso out of the boot
graph.

Desktop initial JS 1,558 -> 1,024 KB; boot JS 6.88 -> 6.26 MiB.
@FresnelR27
FresnelR27 force-pushed the perf/bundle-vendor-split-single-virtualizer branch from 42d217e to 5dc399f Compare September 16, 2026 03:22
@Harry19081
Harry19081 merged commit 797c727 into develop Sep 16, 2026
15 checks passed
@Harry19081 Harry19081 added performance Performance, responsiveness, or resource-usage work dev-tooling Developer tooling, build, CI, tests, diagnostics, or release labels Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dev-tooling Developer tooling, build, CI, tests, diagnostics, or release performance Performance, responsiveness, or resource-usage work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants