Studio: load the settings tab panels when they are shown, not at launch by danielhanchen · Pull Request #8966 · unslothai/unsloth · GitHub
Skip to content

Studio: load the settings tab panels when they are shown, not at launch - #8966

Merged
danielhanchen merged 24 commits into
mainfrom
perf-settings-lazy
Aug 18, 2026
Merged

Studio: load the settings tab panels when they are shown, not at launch#8966
danielhanchen merged 24 commits into
mainfrom
perf-settings-lazy

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

SettingsDialog is rendered at the app root and is closed for the whole launch, but it statically imported all twelve of its tab panels, so the browser fetched, parsed and executed every one of them before the first paint.

This loads each panel when it is first shown, and prefetches the rest on idle once the dialog has been opened, so a tab click never waits on a round trip.

Results

Ten interleaved fresh Chromium contexts per side against production builds with the HTTP cache disabled, each side served from its own loopback origin, alternating which side goes first. Medians.

1x CPU 6x CPU
metric main this change main this change
Largest contentful paint 734 ms 736 ms 1,892 ms 1,802 ms
DOMContentLoaded 300.6 ms 290.7 ms 1,200.4 ms 1,074.2 ms
Decoded resources 6,386.5 KB 5,992.8 KB same same
Transferred resources 6,401.1 KB 6,013.9 KB same same

394 KB less to decode. On a fast machine over loopback that buys about 10 ms and LCP does not move; the effect is in parse and execute, so it grows with how slow the machine is, and at 6x throttling it is 126 ms off DOMContentLoaded and 90 ms off LCP. The desktop app's embedded webview and the machines that report slow launches are the throttled end of that range, not the 1x end.

Two numbers worth stating plainly because they are smaller than a static reading of the bundle suggests. Walking the entry chunk's static import closure says the settings chunk is 2,493 KB of a 5,207 KB eager total, which looks like a much larger win than this. It is not: most of that chunk is code shared with the chat page, which is mounted at the root and is eager regardless, so removing the static edge mostly moved the attribution between chunks. The eager total falls from 5,224.8 KB to 4,832.2 KB raw, and 1,503.7 KB to 1,414.5 KB transfer, measured on this branch merged up to 2cf7a2888. The measured 394 KB is the real figure. Those two totals were 5,207.2 KB and 4,814.6 KB before that merge; the ~18 KB is upstream main's own growth, not this change, and both sides move together.

Change

  • TAB_LOADERS maps each tab id to a dynamic import; LAZY_TABS wraps them in React.lazy. The panel area gets a Suspense boundary.
  • Once open is true, scheduleIdleTask walks TAB_LOADERS and warms the rest. The prefetch reads the same map the renderer does, so a tab cannot be lazy in one place and missing from the other.
  • Nothing runs while the dialog is closed, which is its state for the entire launch.
  • The dialog's own shell, its search index, and the @/features/settings barrel are untouched, so no importer changes.

What happens when a panel cannot be fetched

Fetching a panel is a new way for the dialog to fail, and it is worth being explicit about, because before this the panel code was already resident and could not.

  • The panel area sits in an error boundary that offers a reload. Without one the throw reaches the React root, which unmounts the whole tree, so a single missing chunk took all of Studio down rather than one panel. That is not theoretical: blocking one panel's module in a browser reproduced it, and the dialog, its nav and the rest of the page went with it.
  • Reload rather than retry. React caches a lazy rejection for the life of the page, and the browser's module map caches the failed import, so re-importing the same URL rethrows without a new request. index.html is served no-store, so a reload does pick up the current chunk names.
  • The Suspense fallback is a loading line that only appears after 300 ms, so a slow first open shows something and a prompt one, which is every panel on a local install, still shows no flash.
  • The idle prefetch consumes its own failures. It warms panels nobody selected, so a chunk it cannot reach must not surface as an unhandled rejection for a tab the user never opened.

The reachable case is a page whose entry bundle predates an in-place rewrite of dist/: /assets is served immutable, so already-fetched chunks keep working and only a not-yet-fetched one 404s. The desktop app is not exposed, since Tauri embeds the frontend in the binary and an update replaces the whole bundle and relaunches.

Coverage

tests/settings-tab-panel-loading.test.ts parses the import declarations with the TypeScript compiler, rather than grepping, because a deferred import(...) is a call expression and a static one is a declaration, which is exactly the distinction being bought:

  • the dialog has no static ./tabs/*-tab import, and has one deferred import per panel file on disk
  • nothing anywhere else under src/ statically imports a tab panel, since one such edge from any eagerly reached module puts all of them back
  • the idle prefetch is still wired to TAB_LOADERS, and consumes a rejected load
  • every panel Suspense is inside a class that defines getDerivedStateFromError, walked over the JSX rather than matched on text

tests/studio/playwright_settings_tabs.py drives the real dialog in a browser against smoke-settings.html: all twelve tabs render when selected, deep-open lands on the same panel as clicking to it, the settings search jumps to a row and flashes it, and a blocked panel module leaves the dialog and its twelve nav entries standing with another tab still working.

Testing

  • npm test (2,786 passed)
  • npm run typecheck
  • npm run i18n:check:strict
  • npm run build
  • npx eslint on the changed files, clean
  • tests/studio/playwright_settings_tabs.py on chromium, firefox and webkit, plus the blocked-module case. Every tab's settled panel is identical to main's, as is deep-open and the search jump.
  • tests/studio/playwright_settings_tabs.py re-run on all three engines after merging main, plus the blocked-module case three times
  • The measurement above, scripted and interleaved rather than run by hand

chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen and others added 3 commits August 16, 2026 08:22
…down

A panel is fetched the first time it is shown, so it can now fail where it
could not before: offline, or a page whose entry bundle predates an in-place
rewrite of dist/ and still names chunks that have been replaced. The dialog is
mounted at the app root and nothing above it catches, so the throw unmounted
the whole of Studio rather than one panel. Blocking a panel's module in a
browser reproduced it: the dialog, its nav and the rest of the page went.

The panel area now sits in an error boundary that offers a reload, and the
Suspense fallback is a delayed loading line rather than an empty pane, so a
slow first open shows something and a prompt one still shows no flash.

Reload rather than retry: React caches a lazy rejection for the life of the
page and the browser's module map caches the failed import, so re-importing
the same URL rethrows without a new request. index.html is served no-store,
so a reload does pick up the current chunk names.

tests/settings-tab-panel-loading.test.ts gains a case that walks the JSX and
asserts every panel Suspense is inside a class that defines
getDerivedStateFromError. tests/studio/playwright_settings_tabs.py drives the
real dialog in a browser: all twelve tabs, deep-open, the search jump, and the
blocked-module case.
The idle prefetch warms every panel once the dialog opens, so a chunk it
cannot fetch reached the page as an unhandled rejection for a tab nobody had
asked for. Reproduced by blocking one panel's module in a browser: the
rejection landed on window even though the boundary handled the panel that was
actually on screen.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…ling on a placeholder

tsconfig.app.json lists the three existing smoke entries explicitly, so the new
one was outside the project and npm run typecheck skipped it. Added, and the
harness handle it installs on window is now optional, since app code sits in
the same project and must not be able to reach a handle only the harness page
installs. tsc --listFiles now names the file.

The Playwright harness settled on whatever held still for 600ms. The panel
renders from a deferred value, so a switch keeps the outgoing content up until
the incoming panel is ready, and on a loaded machine that hand-off outlives the
window: one run read the placeholder as the final panel and called a correct
recovery a failure. It now refuses to settle on something with almost no
content. A run that dies on a cold dev server also writes its report instead of
leaving none.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: a0632ee425

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 84d72a5561

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

tests/test_source_read_encoding.py holds every checked-in file read and write
in the test trees to an explicit utf-8, so it does not depend on the platform
default and break on Windows the day the file gains a non-ASCII byte. The
report write was the one that did not.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 3cc212897c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…heck

Pre-existing flake in this step, not something this branch introduced. The
step reads scrollTop straight after keyboard.press, but Radix scrolls the
highlighted item into view off the back of that keypress, so the value is a
mid-scroll sample: instrumented on the ubuntu CI image the viewport went on
to settle 24-35px further down in 20 runs out of 20, on this branch and on
its merge base alike.

Two things break as a result. The stale sample is not the floor the wheel
has to beat, which is why the failure reads '20 -> 44' as though the
viewport had moved the wrong way when 44 is simply where the keyboard
scroll ended up. And a wheel dispatched into a scroll Chromium is still
animating can be swallowed outright, which is the actual failure: at a
maximum scrollTop of 243 a working -400 wheel lands on 0 every time.

So wait for the scroll to stop before taking the floor, keep the pointer
inside a viewport that is not always 40px tall, and re-send the wheel on a
bounded retry. A viewport that genuinely refuses the wheel still never
moves and still fails, just after more tries.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 1ca72e9add

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from danielhanchen Aug 18, 2026
@unslothai unslothai deleted a comment from danielhanchen Aug 18, 2026
@unslothai unslothai deleted a comment from danielhanchen Aug 18, 2026
@unslothai unslothai deleted a comment from danielhanchen Aug 18, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 77afa66909

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: ff61e53916

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@unslothai unslothai deleted a comment from oobabooga Aug 18, 2026
@unslothai unslothai deleted a comment from oobabooga Aug 18, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: ccaa51eea0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

The abandoned-deep-open step routed every request through a handler that
sleeps 2.5s, and that sleep runs on the driver thread, so the whole page's
module load queued behind it and arrived at the main thread in one go. On a
two-core runner sharing the box with a live Studio that pushed the reopen
past its 15s timeout, which reads as a settings dialog that would not open
when nothing was wrong with it. Route the Data module alone.

The assertion is unchanged and still goes red on the pre-change store: the
next ordinary visit to Data reopens the archive listing.
danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 18, 2026
# Conflicts:
#	.github/workflows/studio-frontend-ci.yml
#	studio/frontend/tsconfig.app.json
@danielhanchen

Copy link
Copy Markdown
Member Author

Merged main in and resolved the conflict

main moved (8969, 9048) and this branch went CONFLICTING. Conflicts were in .github/workflows/studio-frontend-ci.yml and studio/frontend/tsconfig.app.json, and both sides were purely additive: main added a smoke-stream-pacing entry and a non-gating smoke step, this branch adds its own. I kept both sides and dropped nothing.

Checked rather than eyeballed: I re-parsed the merged workflow and diffed the step-name set and the pull_request.paths set against each side. No step and no path trigger is missing from either. The tsconfig include carries both entries.

Post-merge on this branch: tsc -b tsconfig.app.json clean, npm test 3694/3694.

Re-proved a guard still goes red, because a merge that quietly neuters one is the failure mode worth guarding against: removing the chat entry from the lazy panel map in settings-dialog.tsx turns exactly one test red in the settings panel-loading suite (16 passed becomes 15 passed, 1 failed) and green again restored.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 0b1d93be2e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Vite dev proxies /api to 127.0.0.1:8888. With a Studio listening there and
no token those calls answer 401, the app's auth handling navigates, and the
harness window goes with it, after which every step times out waiting for a
dialog that cannot exist. It happens on main too, where the harness is gone
before the first open, so it says nothing about the panels. Report it.
@danielhanchen

Copy link
Copy Markdown
Member Author

Gate 1: is this worth merging, and does it break anything

Verdict: yes. The win reproduces, the rendered UI is identical, and nothing is silently lost. Three behaviours genuinely differ and are named below rather than left implied, including one that is a real regression and belongs in the PR body.

The win reproduces

Rollup module graph of two production builds:

merge base head
eager static-import closure 5,393,987 B 4,983,678 B (-410,309, -7.6%)
eagerly reachable modules 2,242 2,028 (-214)
total modules in build 4,053 4,053 (nothing duplicated)
modules newly eager 0

Nine interleaved fresh contexts per side, cache disabled, production builds, each side on its own loopback origin: DOMContentLoaded 263.0 ms to 81.6 ms, warm tab switch 59.6 ms to 50.5 ms. Ranges are non-overlapping on every row, so this is not inside the noise floor.

One correction to how this should be quoted: the multi-megabyte decoded-bytes figure from that harness overstates the app, because the measurement entry imports only SettingsDialog. The honest number for the real app is the 410 KB closure above.

The rendered screen is unchanged

Live Studio, real backend, real hardware, all 12 tabs, two passes per side: zero differences in labels, control inventory, values, checked and disabled state, ordering, positions or element counts. Every one of the 16 raw-text diffs is the two servers being two servers: live CPU and RAM samples, differing ports inside generated commands, and each server's own log filename.

Old and absent state behaves identically too: first launch with no localStorage, state written by an older build, corrupt JSON, and a localStorage that throws are byte-identical between the two trees. No persisted format changed.

The three behaviours that genuinely differ

  1. Cold first open costs about 160 ms, 176.8 ms to 337.0 ms, and the panel area is blank for roughly 300 ms rather than never being empty. Deferred, not lost. The 300 ms fallback delay holds: zero fallback flash frames in all 28 runs, including under prefers-reduced-motion.

  2. Opening Settings while streams are in flight is worse, and this is a real regression. On an HTTP/1.1 origin with five held SSE-shaped connections, seven repetitions:

    merge base head
    first panel visible 196 ms 1,448 ms
    next tab switch 56 ms 461 ms

    The merge base is unaffected, because its panels are already in the entry chunk. This is the browser's per-origin connection cap, it is bounded (once per page load, and the content does arrive), but it is the honest cost of the change and it was not stated anywhere.

  3. A pre-existing bug is fixed rather than caused. Requesting the archive deep-open and immediately closing left the next ordinary visit to Data stranded on the archive subpage. That reproduces on the eager merge base, so lazy loading did not create it; this PR fixes it.

The stale-panel window that useDeferredValue makes possible in principle did not materialise: with 2,000 ms of injected latency the nav-versus-panel mismatch is 42 to 148 ms on both trees, because the idle prefetch has already warmed the module.

No module-evaluation side effect was lost

This is the real risk in this class of change, so it was checked rather than assumed. An AST scan of all 72 lazified modules found 20 module-evaluation effects and every one is an inert new Set, new Map or createContext. No window.addEventListener, no fetch, no registration at module scope. The stores that other code depends on stay eager through the barrel, and use-hardware-info was already lazy on both sides.

CI triage

Cross-platform on staging is green on ubuntu-latest, macos-14, windows-latest and all three frontend jobs, and org studio-frontend-ci is green including both new steps.

staging-8966 studio-playwright is red, and it is the harness rather than this PR. The smoke page is served by vite, which proxies /api to 127.0.0.1:8888; a real Studio listening there answers 401 without a token and the app's auth handling navigates away, taking the harness window with it. The instrumented report shows "tabs": {} with only two 401s in the console. That proxy target is byte-identical on the merge base and on this branch, so it is not this PR's doing, and the merge base in fact fails harder, with window.__settingsSmoke undefined before the first open. Mac Studio GGUF CI is failing on staging main itself.

Two test-robustness fixes pushed as a result: 4b1b46ca0 narrows a deep-open test that was routing every request through a handler sleeping 2.5 s, and d68b439a0 makes the harness name that 401-and-navigate cause instead of timing out on a selector.

Stated gaps, rather than implied coverage

The hardware-exposing panels were exercised against a real CUDA backend on Linux with NVIDIA. That is one cell; staging covered Linux, macOS and Windows but CPU-only, and AMD, WSL and macOS-with-GPU were not run. No module that reads hardware changed eager or lazy status. Playwright WebKit is a proxy for the WKWebView, WebKitGTK and WebView2 that Desktop embeds, not those webviews, and shipping Safari cannot be tested here. A real deploy-in-flight chunk 404 was simulated by blocking the module, not reproduced against a live CDN.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

@danielhanchen
danielhanchen merged commit 2073e0f into main Aug 18, 2026
34 of 37 checks passed
@danielhanchen
danielhanchen deleted the perf-settings-lazy branch August 18, 2026 07:11
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