Skip to content

fix(settings): keep heading and tab strip mounted while a tab loads - #6340

Open
viktormarinho wants to merge 1 commit into
mainfrom
t3code/fix-settings-tab-loading
Open

fix(settings): keep heading and tab strip mounted while a tab loads#6340
viktormarinho wants to merge 1 commit into
mainfrom
t3code/fix-settings-tab-loading

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Switching tabs on a tabbed settings screen (Billing, Members, Connect, Storage) flashed a full-screen spinner over the whole page — the heading and the tab strip disappeared along with the content.

Root cause is two boundaries, not one:

  1. TanStack wraps every route match in its own <Suspense fallback={pendingComponent}> (Match.tsxResolvedSuspenseBoundary), and router.tsx sets a global defaultPendingComponent: SplashScreen — a min-h-screen centered spinner. So anything that suspends inside a settings page (the lazy route chunk on the first click of a tab, or a suspense query) replaces the entire route subtree. The page-local <Suspense> boundaries some pages already had never got a chance for the chunk case, because the chunk suspends before the component renders at all.
  2. Members and Roles put their own <Suspense> above the subnav, so useMembers() blanked the chrome even once the chunk was cached.

Changes

New apps/web/src/components/settings/settings-group-page.tsx owns both halves:

  • SettingsGroupPage — the shared shell for tabbed settings screens. Renders Page → Page.Content → Page.Body → SettingsPage with the heading + tab strip above an ErrorBoundary/Suspense pair, so page data only ever swaps the content region for a skeleton. Neither hook behind the tab strip suspends (useCapabilities, useOwnedSites are plain useQuery), so the chrome is safe outside the boundary — and safe to render from inside a fallback.
  • settingsGroupPendingComponent(group) — route-level pendingComponent, wired onto all 9 tabbed routes in router.tsx. Overrides the global SplashScreen so a cold chunk load paints the same chrome + skeleton.

All 9 grouped pages now go through it: connect/api-keys, members/roles, billing/ai-providers/infra-billing, buckets/synced-repos. Net −87 lines (each page dropped its own Page/SettingsPage/Suspense/ErrorBoundary scaffolding).

Roles keeps a plain spinner when ?role= is set — the role editor is a full-bleed screen with its own heading and shouldn't get the tab strip; that branch is decided from the URL, which never suspends.

Untabbed settings pages (General, Secrets, Store, Monitor, Tasks) are unchanged — no chrome to preserve.

Affected areas

/$org/settings/{connect,api-keys,members,roles,billing,ai-providers,infra-billing,buckets,synced-repos}

Behavior change worth flagging

On the Members page, JoinRequestsSection used to render above the "Members" heading; it's now the first item under the tab strip. Happy to add a slot above the subnav if the original placement was deliberate.

Testing

  • bun run --cwd=apps/web check — clean
  • bun run lint — no new findings
  • bunx knip — clean
  • bun run fmt — clean

Not exercised in a browser: no dev server was up in this worktree and booting the stack needs an .env plus a seeded org. Worth a manual pass clicking through each tab group (including a hard reload so the chunks are cold) before merge.


Summary by cubic

Keep the settings heading and tab strip visible while a tab loads. Previously, any suspense replaced the entire page with a full-screen spinner; now only the content area shows a skeleton.

  • Introduces SettingsGroupPage to render the heading and tab strip above an ErrorBoundary/Suspense pair so only the content region swaps during data load.
  • Adds settingsGroupPendingComponent(group) on tabbed routes to override the global SplashScreen, keeping the same chrome + skeleton during cold chunk loads.
  • Applies the shared shell to connect/api-keys, members/roles, billing/ai-providers/infra-billing, buckets/synced-repos. Untabbed settings pages are unchanged.
  • Roles: when ?role= is set, keep a centered spinner (full-bleed editor without tabs).
  • Members: JoinRequestsSection now renders under the tab strip.
  • E2E: adds a test that holds the Buckets fetch and asserts the heading and tabs remain visible; adds data-slot="settings-heading" and data-testid="settings-content-loading" for assertions.

Written for commit 9717e6e. Summary will update on new commits.

Review in cubic

Switching tabs on a tabbed settings screen (Members, Connect, Storage,
Billing & AI) flashed a full-screen spinner over the whole page, taking
the heading and the tab strip with it.

TanStack wraps every route match in its own <Suspense> whose fallback is
that route's pendingComponent, and the router sets a global
defaultPendingComponent of SplashScreen (a min-h-screen centered
spinner). So anything that suspended inside a settings page — the lazy
route chunk on the first click of a tab, or a suspense query — replaced
the entire route subtree. Page-local boundaries never got a chance for
the chunk case, and Members/Roles had theirs above the subnav anyway.

Add SettingsGroupPage, the shared shell for tabbed settings screens: it
renders the heading and tab strip above an ErrorBoundary/Suspense pair,
so page data only ever swaps the content region for a skeleton. Neither
hook behind the tab strip suspends (useCapabilities and useOwnedSites
are plain useQuery), so the chrome is safe outside the boundary.

Pair it with settingsGroupPendingComponent(group) as the route-level
pendingComponent on the eight tabbed routes that render a component,
overriding the global SplashScreen so a cold chunk load paints the same
chrome plus skeleton.

Roles keeps a plain spinner when ?role= is set — the role editor is a
full-bleed screen with its own heading and shouldn't get the tab strip.
Untabbed settings pages are unchanged.

Cover it in packages/e2e/tests/settings-navigation.spec.ts: hold the
Buckets tab's FILE_CONFIG_LIST call open and assert the heading and tabs
are still on screen while the content skeleton shows.
@viktormarinho
viktormarinho force-pushed the t3code/fix-settings-tab-loading branch from a7aba37 to 9717e6e Compare August 20, 2026 17:35
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.

1 participant