Draw loading states as the page you are waiting for - #111
Merged
Conversation
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
force-pushed
the
streamline-app-skeletons-loading
branch
from
September 3, 2026 23:07
64ad23f to
6e19f88
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Non-goals
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.
DataTableand the newDataTableSkeletonshare oneTableShell, 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.
RouteTenantProviderwraps the whole dashboard and returned its placeholder instead ofchildrenwhile 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>insideSidebarProvider'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:
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-e2e—SourcePaseo.start()packs six workspaces and installs the tarballs into a fresh directory, and it runs frombeforeEach, 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 toPASEO_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 — herephase-two, and onrequire-daemon-selectionandadd-provider-read-rpcs-hubit was whichever test drew the short straw. AglobalSetupnow 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 leavePASEO_E2E_WORKTREEunset build nothing and skip.test—waitForRecoveredExecutionwaited 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 —spawningwhere the caller assertedrunning. 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.src/components/app/data-table-skeleton.test.tsx(3 tests) plus the existingprovider-section-loadingtest — green.dashboard-desktop(6),dashboard-navigation(3),apps(16),triggers(2),authentication(5),entitlements(3),daemon-handoff(2).For the CI fixes:
phase-tworeproduced locally with the old code (120s timeout, build inside the test) and now gets pastSourcePaseo.start()to the CLI step; it cannot complete locally because this machine's Paseo checkout is not the pinnedPASEO_E2E_COMMIT.e2e/prebuilt-source.spec.tsasserts a worker is handed a packed tree containingnode_modules/.bin/paseo, and skips cleanly with no source checkout. Both paths run.src/e2e/harness/source-paseo.packaging.test.tscovers one build per checkout, separate checkouts kept apart, and one shared failure rather than a retry storm.daemons.test.tsandindex.bootstrap.test.tsin 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
Tablederives 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 makesgetByRole("region", { name })ambiguous. That brokedashboard-desktop.spec.tslocally 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.RouteTenantProvidernow always renders its children. Panels callinguseRouteTenant()still never render while the tenant is unresolved, because the shell returns the skeleton from its content slot instead of theOutlet. If a future consumer mounts outside that slot it would see anullcontext and throw. The provider is used in exactly one place today.queryStategained an optionalpendingelement. 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.globalSetuppublishes the packed tree throughprocess.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.