…es the canvas on "Loading…" (objectui#11331) (objectstack-ai#11338)
Fixes objectstack-ai#11331
Clause-②: no
A Studio pillar whose draft load is cancelled, with no load to replace
it, no longer leaves its canvas on "Loading…". The Interfaces race
removed here is the one that keeps `Test (shard 2/8)` red on PR
objectstack-ai#11323
(`StudioDesignSurface.canvasFrame.test.tsx`, "a canvas state with no
preview shell keeps the wrapper frame"). That file is unchanged here. It
goes green because the race is gone, not because it waits longer.
## The rule, one shape for the three draft loads
Each draft-load effect (`InterfacesPillar`, `DataPillar`,
`AutomationsPillar` in `StudioDesignSurface.tsx`) raised `loading` as
its load started. Only the load's own `finally` lowered it, behind `if
(!cancelled)`. **The rule now:** the cleanup of a run whose load has not
settled takes back what that run's start claimed. Each run keeps a local
`settled` flag, which its `finally` sets. Its cleanup runs `if
(!settled)` and does the following:
- **all three pillars:** `setLoading(false)`.
- **Data only:** it also releases the load-once claim
(`loadedNameRef.current = null`). This is the same rule applied to a
second claim, the only other claim a run's start makes. The Data run's
start claims two things: `loading` and `loadedNameRef`. The measurement
below shows that lowering `loading` alone leaves the Data case stuck.
**Why it cannot raise a second spinner or flash stale content:** when a
replacement load follows, the old run's cleanup `setLoading(false)` and
the new run's `setLoading(true)` happen in the same passive-effect
flush, so React batches them into one render. Even without that
batching, the canvas's `loading || !draftLoaded` arm (or `!objLoaded` in
Data) keeps the new item on the spinner until its own load is installed,
because the objectui#11272 buffer stamp names the item it was loaded
for. The stamping (`draftFor`, `ifDirty`, `hasDraft`, the `!draftLoaded`
arm and its `error && !loading ? null` rule) is unchanged, and the
canvas branch order is unchanged.
## What was measured (Zone 2)
1. **Interfaces, red first on `origin/main` `5262f7dd3f`.** The gated
probe is now a committed pin. It mounts the real `InterfacesPillar` with
`canvasFrame.test.tsx`'s mocks, holds the dashboard leaf's `layered`
read on a gate, and clicks the report leaf while that read is in flight.
On `5262f7dd3f` the race row fails with the DOM caption `Sales report`
over a canvas body that reads `Loading…`. A scratch probe asserted only
the issue's "after the gate opens" column, and on the ablated tree it
fails the same way after the gate opened. The control row, a click after
the dashboard grid rendered, shows "cannot be previewed or designed
here" on both trees.
2. **Data: reached through the component.** The pin re-renders the
pillar with a new client object between the start of the load and its
release. On `5262f7dd3f` the object stays on `Loading…` after the gate
opens. The cause is `!objLoaded`, not `loading`. The cancelled load
installed nothing, and the re-run bailed on `loadedNameRef.current ===
loadKey`. **Lowering `loading` alone does not fix this site** (ablation
leg D1 below: the race row stays red). Releasing the unsettled claim is
what fixes it. A claim whose load settled is kept, and a pin row says
so: a new client after the object loaded issues no second `layered`
read.
3. **Automations: not reachable through the component, so it has no
row.** Its effect returns early only on `!current`, and the pillar never
sets `current` back to null once a flow is open. Its three `setCurrent`
writes are the list effect's `c ?? deepLinked ?? items[0] ?? null`, a
create and a rail click, all of them non-null once a flow is open. So a
cancelled flow load always has a replacement. On `!current` the canvas's
`!current` branch comes before the loading arm, so no spinner is visible
with no leaf. That is a reading of the source; nothing in the tree
re-derives it. The cleanup takes the same shape here as at the other two
sites.
4. **One shape:** the `settled`-gated cleanup above, chosen over
clearing in every early-return branch. A branch-by-branch clear covers
only the early returns that exist today, and it does not cover the Data
bail at all. The cleanup is the one place every cancelled run passes
through.
## Pins: `StudioDesignSurface.cancelledLoad-11331.test.tsx`
| row | `5262f7dd3f` (no fix) | this head |
|:--|:--|:--|
| Interfaces race: report leaf clicked while the dashboard load is in
flight | **red** (`Loading…` under the `Sales report` caption) | green |
| Interfaces control: the same click after the dashboard rendered |
green | green |
| Data race: a new client while the object load is in flight | **red**
(`Loading…`, no `Add field`) | green |
| Data control: the same gated load with no new client | green | green |
| Data guard: a new client after the object loaded reads nothing again |
green | green |
## Ablation (fix committed first, each leg mutated through
`ablation-replace.mjs`, restore proven by blob hash and an empty `git
diff HEAD`)
| leg | mutation | red rows | green rows |
|:--|:--|:--|:--|
| I | Interfaces cleanup line removed | Interfaces race, and the scratch
probe's after-gate race row | the other 4 pin rows, and the probe's
control |
| D1 | Data keeps only `setLoading(false)`, so the claim is not released
| Data race | the other 4 pin rows, and both probe rows |
| D3 | Data releases the claim even when the load settled | Data guard
(`layered` read twice) | the other 4 pin rows, and both probe rows |
## Local verification (head `7b70a05236`)
- `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...'
build`: exit 0. Then `pnpm --filter @object-ui/app-shell type-check`
(`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles`
shows the test project compiles the new pin.
- `pnpm exec vitest run packages/app-shell/src/views/studio-design/`:
`Test Files 85 passed (85)`, `Tests 496 passed (496)`.
- Repeated runs, one vitest process each: the new file passed 10 of 10
runs and `StudioDesignSurface.canvasFrame.test.tsx` passed 10 of 10.
- Gates, each at exit 0: `check:new-line-citations` (0 new),
`check-changeset-presence`, `changeset:check`, `check:control-bytes`,
`check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:test-path-roots`,
`check:changeset-claims` (report-only; the pending changeset it names is
about chunk co-tenancy, which this diff does not move),
`check:pending-changeset-literals`.
- ESLint on the two changed files only (`--no-inline-config`): the new
test has 0 findings. `StudioDesignSurface.tsx` has the same rule-by-rule
counts as `5262f7dd3f` read through `--stdin`, which is 1 error
(`react-hooks/static-components`) and 18 warnings, so nothing was added.
The config enables no type-aware linting, so this diff cannot move a
verdict in an untouched file. The repo-wide lint is CI's.
- ⛔ No timeout was raised and no `heavyDomTests` entry was added.
## Acceptance notes
- Changeset: `.changeset/11331-app-shell-cancelled-load-spinner.md`,
`@object-ui/app-shell` `patch`.
- CI was not awaited (the seat owns convergence). `Spec Main Shape Gate`
is expected red from objectui#11330, which this diff does not touch.
- Reach of the Data site: the pin reaches it through a
`useMetadataClient` re-mint, which the hook's `useMemo` can hand out
(AGENTS.md objectstack-ai#10). No console flow that re-mints the client mid-load was
exercised.
Session: `https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ`
---
_Generated by [Claude
Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11293
Clause-②: yes
What this changes
A standalone
object-kanban/object-calendarnow honours the spec'spagenavigation mode, and anavigationblock written withoutmode(which takes the spec'spagedefault), by opening the record page.drawerand the other members behave as before, and a parent view's click handler still takes the click first.The rule lives in ONE place, the shared hook, per triage's direction (comment 5922527918): no per-renderer copy, and no narrowing of the spec's mode set.
@object-ui/react—useNavigationOverlay. In thepagebranch, when noonNavigatewas supplied, the click goes toopenRecord(objectName, recordId)onRelatedRecordActionsContext. That is the record navigator the HOST publishes, and the same seam the grid's link column and the lookup cells already read. The hook still builds no record URL forpage. A suppliedonNavigatestill wins, so the grid keeps its explicit wiring. AnonRowClickstill takes the click before either.@object-ui/app-shell—PageViewpublishes the console's record navigator to the blocks on a custom page. This is the producer-side half (see "Where the fallback lives" below). It lives in the new moduleviews/pageRecordActions.ts.pageRecordActionsValueaddresses/apps/APP/OBJECT/record/IDfor the objects the console routes to.resolvereturns no related-list handlers, on the model of the list page'slistRecordActionsValue.@object-ui/plugin-kanban/@object-ui/plugin-calendar. No renderer code changes: both already hand the hook theirnavigationand object name. The registrations'navigationinput descriptions and docblocks retire the "page warning". They now say wherepagegoes: through the host's record navigator, and nowhere under a host that publishes none. The READMEs,content/docs/plugins/plugin-kanban.mdx,plugin-calendar.mdxandcontent/docs/api/schema-reference.mdfollow.kanbanNavigationMembers-8652andcalendarNavigationMembers-8652re-point their two "page warning" rows. Under a host that publishesopenRecord,mode: 'page'and a mode-less{ size: 'lg' }each call it with(object, '1'), with no overlay and nowindow.open. Beside them:drawerunder the same host is the lit control, a parentonRowClicktakes thepageclick first (precedence control), and with no host navigatorpageopens nothing (the description's remaining claim). The parity ledger's twopinssummaries follow.useNavigationOverlay.hostRecordNavigator-11293(react hooks) pins seven rows:page; mode-less;onNavigatewins;onRowClickwins; overlay lit control; absentnavigationunchanged; no host or no object name.PageView.recordNavigator-11293(app-shell) pins that a block on the page reads a navigator,openRecordroutes through the router, an unroutable object opens nothing, andresolveyields no handlers.Zone 2 measurements (dispatch order)
pnpm exec vitest runon the twoNavigationMembers-8652files. Result:Tests 4 failed | 27 passed (31). The 4 red rows are exactlypageand mode-less on each block:expected "vi.fn()" to be called 1 times, but got 0 times. Thedrawerlit control, the parent-onRowClickcontrol and the no-host row were green.ObjectGridhas no record navigator of its own. Its hook call passesonNavigate: onNavigate ?? schema.onNavigate, which is host-supplied only (the console'sObjectView,ObjectDataPageandInterfaceListPagepass router closures). The only context navigator the grid reads isRelatedRecordActionsContext.recordHref/openRecord, for its link column. Measured with a throwaway probe, deleted before commit: a standaloneobject-gridwithnavigation: { mode: 'page' }and noonNavigateproducedopen: 0, openRecord: [], pushState: 0, dialog: false, with and without a provider. So "as the grid does" holds only when a host supplies the navigator, and the grid had the same dead click. The fix closes it for the grid too, and for the gallery, map, timeline and tree.@object-ui/reactowns both the hook andRelatedRecordActionsContext, so reading the context adds no dependency. The hook therefore owns thepagefallback, and the grid keeps its explicit wiring. The order's "no" branch (both renderers passonNavigatethe way the grid does) would not reach the standalone case: a block on a custom page is a document, nothing passes it a function, and the calendar'sonNavigateprop already means date navigation. One measured gap remained:PageView, the host of standalone blocks on custom pages, published no navigator. The record page (RelatedRecordActionsBridge) and the list page (listRecordActionsValue) did. So the producer-side half lands here too, as the order allows ("if the real producer is elsewhere, fix it at the producer").inputsand docblocks. Their pins are re-pointed as described above.onRowClickkeeps its behaviour. This is pinned at both mounts under a host navigator, and in the hook test.Boundaries, stated
navigationis unchanged. Only an AUTHORED block that resolves topagereads the published navigator, and that limit is load-bearing. A host withholds navigation by passing neither callback:RelatedListrenders mobile rows as anobject-gallerywith nonavigation, and withonRowClickundefined when the bridge withheldonView. Reading the navigator on the absent-key path would open those records anyway. The hook test pins this row.{}and no config still agree whenever anonNavigateis supplied (the existingmodeDefaultrows stay green).new_windoware untouched.@object-ui/app-shellchangeset declares it.Tests (final runs on merged head
698710c094; main merged atcd5b19a7e7)pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-kanban^...' --filter '@object-ui/plugin-calendar^...' --filter '@object-ui/app-shell^...' build. Then type-check:pnpm --filter @object-ui/react --filter @object-ui/plugin-kanban --filter @object-ui/plugin-calendar --filter @object-ui/app-shell type-check. Each package rantsc --noEmit && tsc -p tsconfig.test.json. Result:type-check: Done×4, VERDICT command-exit 0.--listFileson eachtsconfig.test.jsonshows the touched test files are inside its scope.pnpm exec vitest run --maxWorkers=2 packages/plugin-kanban/ packages/plugin-calendar/ packages/react/src/hooks/ apps/console/src/__tests__/registry-inputs-spec-parity.test.ts. Result:Test Files 160 passed (160),Tests 1436 passed | 95 skipped (1531).git grep -lfinds foruseNavigationOverlayor the 8652 pins, plus every suite that mountsRelatedRecordActionsProvider/RelatedRecordActionsBridge/listRecordActionsValue(58 files). (b) The app-shellviewstop-level suites,views/__tests__, and the suites that readPageView.tsx: the remount ratchet,page-key-read-census,vite-declared-lazy-views,page-dom-leak-whitelist-7933,residue-namespaces-3546(183 files). Result:Test Files 231 passed (231),Tests 2292 passed | 9 skipped (2301).pnpm check:new-line-citations(0 new citation(s)),pnpm check:control-bytes(OK),pnpm changeset:check(OK),node scripts/check-changeset-presence.mjs(OK, 4 changesets),check:vi-mock-override-shape/check:vi-mock-inherit/check:vi-mock-specifiers/check:test-path-roots(OK).node scripts/check-governed-queue-guard.mjs --testover the 19 paths: NOT GOVERNED..ts/.tsxfiles (--format json): 0 errors. The warnings are all pre-existing.PageView.tsxcarries 5no-explicit-anyat base and 5 now, and the kanban/calendarindex.tsxchanges are string-only. The narrowing is a measurement, not a skip:eslint.config.jsenables no type-aware linting (noparserOptions.project/projectService), and neither the config noreslint-rules/reads other files from disk. So this diff cannot move a verdict on an untouched file.0919d4aa50; the merge touched neither ablated file), through the anchor-must-hitablation-replace.mjswith a verified restore:openRecord(objectName, ...)call was replaced (anchor 1 to 0, blob changed). Result:Tests 6 failed | 32 passed (38), exactly the sixpage/ mode-less rows (2 hook, 2 kanban, 2 calendar). Every control stayed green. Restored: blob equal to HEAD,git diff HEADempty.PageView: the providervaluewas replaced withnull. Result:Tests 2 failed | 3 passed (5), the two mount rows. The pure builder rows stayed green. Restored: blob equal to HEAD.@object-ui/reactthrough the root vitest alias tosrc. The ablation therefore exercised source, and needed no rebuild.check:sdui-registration-pins, reason: it reads the console bundle (PREREQUISITE: build@object-ui/consolefirst).check:readme-exports, reason: it needs every package'sdist(the README edits are prose only, with no fenced imports). The console UI was not exercised in a browser.Acceptance notes (observations, not filed)
onEventClickprop is not one of the hook's arms; onlyonRowClickis. So a React host that passes ONLYonEventClick, under a published navigator, now sees apageclick navigate AND its callback fire. Measured producers ofonEventClickonObjectCalendarin this repo: zero (git grep), and the console passes none. Carrier: none.deriveRecordPageHref, which derives fromwindow.location. On a custom page (/apps/APP/page/NAME) it would derive/apps/APP/page/NAME/OBJECT/record/ID. This is a read-only inference, not measured in the console. Carrier: none.new_windowfallback with noonNavigatestill opens an origin-rooted/OBJECT/record/ID, as the existing member pins assert. This PR leaves it untouched. Carrier: none..changeset/8652-kanban-calendar-navigation.mdstill sayspageopens nothing. It ships in the same release as these changesets, which state the new behaviour. It is another card's release note, so it was left as written.Session:
https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ(domain:uiseat 1 dispatch, claim comment 5923202140).Generated by Claude Code