Skip to content

Draw loading states as the page you are waiting for - #111

Merged
boudra merged 2 commits into
mainfrom
streamline-app-skeletons-loading
Sep 4, 2026
Merged

Draw loading states as the page you are waiting for#111
boudra merged 2 commits into
mainfrom
streamline-app-skeletons-loading

Conversation

@boudra

@boudra boudra commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What changed, and why

Loading states across the dashboard now look like the page you are waiting for. Every surface renders what it already knows — its title, section headings, table columns, form section headings — and draws placeholders only where the data goes. Switching organization or opening a dashboard URL cold no longer makes the sidebar and header disappear.

Closes #2.

Goals

  • One loading idiom for records: the real table shell with placeholder rows, at the real row rhythm.
  • One spinner, used only for a small fact inside an already-drawn page.
  • The app shell stays mounted while the tenant read is in flight, and while it fails.
  • No bare "Loading…" text anywhere — it reserves no space, so the page jumps when it resolves.

Non-goals

  • No redesign of any loaded surface. Every screenshot below is identical once the data lands.
  • No change to what is fetched, when, or by which query.
  • Mobile layouts are unchanged; these surfaces already reflow.

The why

Before this, a surface waiting on a read showed one of three unrelated things: a bare line of text (Triggers, the trigger editor, Activity, API keys), one of six near-duplicate pairs of grey blobs whose heights had drifted apart (Daemons, Usage, Operator, Billing, CLI login, Connections, and the tenant/account transitions), or — on the app setup path only — the finished chrome with placeholders only where the data goes. The last of those is the one that tells the reader they are already on the page they asked for, so it became the rule.

DataTable and the new DataTableSkeleton share one TableShell, so a skeleton table cannot drift from the table it stands in for; a test asserts the two shells render byte-identical markup. Page copy that both the loaded header and its pending state need now lives in one constant per panel, for the same reason.

While reproducing the pending states, a real defect surfaced. RouteTenantProvider wraps the whole dashboard and returned its placeholder instead of children while the tenant read was in flight, so the sidebar, the site header and the page container all unmounted. What was left was a bare <section> inside SidebarProvider's flex row, squeezed into a ~385px column at the top-left with no padding. The error branch did the same, so a failed tenant read blanked the app rather than reporting itself in place. The provider now always renders its children and publishes a status alongside the tenant value; the shell reads that status and puts the skeleton or the alert in its content slot — the slot the organization-switch transition already used.

Before / after

The app-shell transition — account resolved, tenant still in flight:

before after
sidebar, header and page container all unmounted; content squeezed into a narrow unpadded column shell intact, only the content slot is a skeleton

Triggers, the trigger editor, Activity, Daemons, Connections, API keys, Usage, Operator and CLI login all move from bare text or grey blobs to their own page shape. Apps was already correct and is unchanged.

Also in this PR: three CI timing fixes

CI came back red on three jobs that touch none of the UI above, each on a timing assumption rather than a defect in the code under test. They are fixed here rather than re-run, in a separate commit.

  • hub-e2eSourcePaseo.start() packs six workspaces and installs the tarballs into a fresh directory, and it runs from beforeEach, so every test after the first rebuilt a byte-identical tree. The fourth exhausted the 120s hook budget. The build is now memoized per process: the source checkout cannot change while a suite runs, and the tree is only read once built (the daemon writes to PASEO_HOME, which stays per-test). Measured locally: 7.3s for the first call, 0ms for the second, same path.
  • browser-e2e — memoizing per process cannot help the first daemon test in a Playwright worker, which still paid for the build inside its own 120s test timeout. That is the arbitrary single daemon test that times out on a loaded runner — here phase-two, and on require-daemon-selection and add-provider-read-rpcs-hub it was whichever test drew the short straw. A globalSetup now packs the source before any worker starts and hands the path down through the environment, so the build is on no test's clock. Suites that leave PASEO_E2E_WORKTREE unset build nothing and skip.
  • testwaitForRecoveredExecution waited for the daemon agent to be attached, then read the row again. Recovery lands in two writes, so the second read returned whatever the status write had managed by then — spawning where the caller asserted running. It now waits for the status the caller names and returns the record that satisfied the wait.

Verification

  • npm run typecheck, npm run lint, npm run format:check — green.
  • New src/components/app/data-table-skeleton.test.tsx (3 tests) plus the existing provider-section-loading test — green.
  • Playwright, desktop-chromium, run locally against a built instance — 37 tests green across the affected surfaces: dashboard-desktop (6), dashboard-navigation (3), apps (16), triggers (2), authentication (5), entitlements (3), daemon-handoff (2).
  • Pending states were photographed by holding server-function responses open against a locally built Hub, one screenshot per surface, in both the old and new code.

For the CI fixes:

  • phase-two reproduced locally with the old code (120s timeout, build inside the test) and now gets past SourcePaseo.start() to the CLI step; it cannot complete locally because this machine's Paseo checkout is not the pinned PASEO_E2E_COMMIT.
  • e2e/prebuilt-source.spec.ts asserts a worker is handed a packed tree containing node_modules/.bin/paseo, and skips cleanly with no source checkout. Both paths run.
  • src/e2e/harness/source-paseo.packaging.test.ts covers one build per checkout, separate checkouts kept apart, and one shared failure rather than a retry storm.
  • daemons.test.ts and index.bootstrap.test.ts in full — 99 passed. The helper is used at 13 call sites, so the whole file matters, not just the one test that was failing.

Risk surface

  • Accessible names. Table derives a landmark named "<label> table", so a panel skeleton passing its own label down to the table inside it puts two regions under one name and makes getByRole("region", { name }) ambiguous. That broke dashboard-desktop.spec.ts locally and is fixed; the panel skeleton now labels its inner table separately. Worth a look if any other surface starts nesting a labelled table inside a labelled region.
  • RouteTenantProvider now always renders its children. Panels calling useRouteTenant() still never render while the tenant is unresolved, because the shell returns the skeleton from its content slot instead of the Outlet. If a future consumer mounts outside that slot it would see a null context and throw. The provider is used in exactly one place today.
  • queryState gained an optional pending element. Callers that don't pass one fall back to the generic panel shape, which is only right where the surface cannot yet say what it is. Connections passes its own; the project panels still use the fallback.
  • globalSetup publishes the packed tree through process.env. Playwright workers inherit it because they are forked after global setup runs; the added spec is what proves that rather than assuming it. The tree is removed when the process that built it exits.
  • The recovery helper is stricter. Callers that pass no status keep the old, weaker wait, so only the one caller asserting a settled status changed behaviour. The other twelve discard the record.
  • Row counts in table skeletons are arbitrary (3, or 1–2 for short tables). They set the height the page settles into, so a table that usually holds many more rows will still shift when data lands.

Every surface waiting on a read showed one of three unrelated things: a bare
line of text, one of six near-duplicate pairs of grey blobs, or, on the app
setup path, the finished chrome with placeholders only where the data goes.
Only the last of those tells the reader they are already on the page they
asked for.

Generalize it. What a surface already knows before the request returns — its
title, section headings, table columns, form section headings — renders for
real, and only what the read decides is a placeholder. DataTable and the new
DataTableSkeleton share one TableShell so a skeleton table cannot drift from
the table it stands in for, and the page copy each panel needs in both states
now lives in one constant per panel.

While reproducing the pending states, a real defect: RouteTenantProvider wraps
the whole dashboard and returned its placeholder instead of children while the
tenant read was in flight, so the sidebar, the site header and the page
container all unmounted. What survived was a bare section inside
SidebarProvider's flex row, squeezed into a narrow column with no padding. The
error branch did the same, so a failed tenant read blanked the app rather than
reporting itself in place. The provider now always renders its children and
publishes a status alongside the tenant value; the shell reads that status and
puts the skeleton or the alert in its content slot, where switching
organization already put its own.

Table derives a landmark named "<label> table", so a panel skeleton must not
pass its own label down to the table inside it — two regions under one name
makes an accessible-name lookup ambiguous.
@boudra
boudra force-pushed the streamline-app-skeletons-loading branch from 64ad23f to 6e19f88 Compare September 3, 2026 23:07
Three jobs went red on work that touches none of them, each on a timing
assumption rather than a defect in the code under test.

`SourcePaseo.start()` packs six workspaces and installs the tarballs into a
fresh directory. In the hub e2e suite that runs from `beforeEach`, so every
test after the first rebuilt a byte-identical tree; the fourth exhausted the
120s hook budget. The source checkout cannot change while a suite runs and the
tree is only ever read once built — the daemon writes to `PASEO_HOME`, which
stays per-test — so one build now serves them all. The memoized value is the
promise, so concurrent starts wait on one build instead of racing.

Memoizing per process cannot help the first daemon test in a Playwright
worker, which still paid for the build inside its own timeout: that is the
arbitrary single daemon test that times out on a loaded runner, seen here as
phase-two and on other branches as whichever test drew the short straw. Global
setup now packs the source before any worker starts and hands the path down
through the environment, so the build is no longer on any test's clock. Suites
that never reach for the source checkout leave PASEO_E2E_WORKTREE unset and
build nothing.

`waitForRecoveredExecution` waited for the daemon agent to be attached and
then read the row again. Recovery lands in two writes, so the second read
returned whatever the status write had managed by then — `spawning` where the
caller asserted `running`. It now waits for the status the caller names and
returns the record that satisfied the wait, rather than a later re-read.
@boudra
boudra merged commit 3f3d25f into main Sep 4, 2026
9 checks passed
@boudra
boudra deleted the streamline-app-skeletons-loading branch September 4, 2026 06:46
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.

fix skeletons

1 participant