…-default writes again (#4211)
"Set as default" could fire no adapter write at all. The filer measured that
`ObjectStackAdapter.updateView` has no early return between its read and its
`saveItem`, so the cause is above it, in the switcher's identity seam.
Views reach `ObjectView` through two independent reads of the same
`type='view'` metadata namespace — `objectDef.listViews` (keyed by the
composer's `<object>.<key>` identity) and the adapter's `listViews()` overlay
rows. Everything that decides whether a view is mutable asks whether a tab's
id is among the overlay keys, so the two reads must spell one view's identity
identically. The overlay side stamped `id` last and was safe; the metadata
side built `{ id: <key>, ...body, ...override }` — `id` FIRST — so an `id` key
inside the stored body or the stored override replaced it. Both really carry
one: `persistViewPatch` writes the whole tab object (its `id` included) back
through `updateViewConfig`, and a duplicated view copies its source
artifact's `id` verbatim.
The tab then sat under an id the overlay read had never heard of, was
classified as a system view, and — because the set-default / rename / delete
entries render only under `!readonly` — the menu entry was ABSENT rather than
present-and-inert. Hence "nothing happened" rather than "refused": the guard's
toast was never reached because there was no control left to click.
One spelling replaces three: `viewRowId` answers a row's identity for the
producer and every reader and is idempotent across the overlay normalization;
`viewEntry` stamps identity last at all three tab-building sites; and
`isSavedViewId` is the single predicate behind both the tab's `readonly` flag
and the handler guard, so the menu and the handler agree by construction.
`buildViewTabs` / `setDefaultViewPatches` are extracted from the `views` memo
and the handler so the seam and the write set are assertable without mounting
the view.
Reverse verification: restoring the clobberable spelling turns 7 cases red
across both divergence blocks with the zero-write signature quoted
(`expected [ 'crm_lead.default', 'my_view', … ]`, `isSavedViewId expected
false to be true`); all 15 control and seam cases stay green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Fixes #4211
Premise: confirmed, and the undetermined half is now settled
The card's premise held on
origin/main@b42558a4c(after #4214 and #4212 landed). The adapter measurement stands —ObjectStackAdapter.updateViewhas no early return between its read and itssaveItem, so the zero-write symptom cannot originate there — and the two candidate routes are both real. What the card left open was which route the QA run hit and why the ids diverged. Both are answered below.Which route: route 2 (the menu entry is absent)
Route 1 (
handleSetDefaultViewtoasts and returns) is unreachable through the UI, and this is decidable statically rather than needing the running app:readonlyflag and the handler'sisSavedViewguard consult the same predicate over the same array —ObjectView.tsx:2022computessaved = savedViews.find(sv => viewRowId(sv) === view.id)/isSystem = !saved, and:1003's guard asks the same question.!isReadonly:ViewTabBar.tsx:564,:667andManageViewsDialog.tsx:300,:361.So whenever the ids diverge the entry is never rendered, and the toast is never reached — there is no control left to click. That is exactly why the symptom reads as "nothing happened" rather than "refused". Route 1 survives only as the handler's own guard against a caller that ignores
readonly; it is pinned as such, not as the QA repro.Why the ids diverged: two measured shapes, one mechanism
Views reach
ObjectViewthrough two independent reads of the sametype='view'metadata namespace:objectDef.listViews(MetadataProvider.applyViewItem)objectName.viewKeyidentitylistViews()overlay rowsnameEverything that decides whether a view is mutable asks whether a tab's id is among the overlay keys, so the two must spell one view's identity identically. They had three spellings to do it with, and one of them was clobberable:
:706):sv.name || sv.idsv.id || sv._id{ id: key, ...body, ...override }—idFIRST, so anidkey inside the merged body or the stored override silently replaced it.Both of those merged bodies are stored documents that really do carry an
id:Shape A — the persisted override row.
persistViewPatch(viewDef.id, viewDef, patch)writes the whole tab object, itsidincluded, throughupdateViewConfig, which stores it undername: viewId.listViewOverridesreads that row back keyed byname,loadViewOverrideshands it to the tab list, and itsidwon. Any view the user personalised (density, sort, hidden columns, column widths, inline edit) has such a row.Shape B — a duplicated view. It copies its source artifact's
idverbatim into the view body;applyViewItemspreads that body intoobjectDef.listViews[key], so the entry carries the foreignid. ObjectView's own overlay-side comment at:709already anticipated this shape ("a duplicate'sidfield … may have been copied verbatim from the source artifact") — the metadata side had no such protection.Either way the tab landed under an id the overlay read had never heard of,
isSavedViewanswered false for a view that is saved, and the tab was presented as a system view with its mutating entries hidden.Measured before the fix, shape B produced three tabs for two views — the source, a phantom tab under the copied id, and the overlay row pushed separately because its key matched nothing.
The fix: one spelling, identity stamped last
In
packages/app-shell/src/utils/viewIdentity.ts(which already ownsdefaultListViewId):viewRowId(row)— the single answer to "what is this row's identity":name, thenid, then_id, skipping empty strings. Idempotent across the overlay normalization (viewRowId(normalized) === viewRowId(raw)), which is what lets one function serve the producer and every reader.viewEntry(id, ...bodies)— assigns the merged bodies, then stampsid. A tab id is now a property of the key the view was looked up by, never of the data.isSavedViewId(savedViews, vid)— the single predicate behind both the tab'sreadonlyflag and the handler guard, so the menu and the handler agree by construction rather than by coincidence.ObjectView.tsxroutes all nine identity sites through them, and two seams are extracted so they are assertable without mounting the view:buildViewTabs(the tab merge, with the three id-bearing steps) andsetDefaultViewPatches(the write set the handler issues).Deliberately not done: the guard was not loosened and the menu item was not force-shown. Both would hand users an action the handler still refuses. A genuine system view stays read-only with its mutating entries correctly absent — pinned as a control.
Evidence
pnpm exec vitest run packages/app-shell/ packages/plugin-view/ --maxWorkers=2New: 22 cases in
ObjectView.setDefaultViewIdentity.test.tsx, 4 inViewTabBar.setDefaultVisibility.test.tsx.Reverse verification — direction predicted before running, and it held. Restoring the clobberable spelling inside
buildViewTabs(the fix taken out with an in-worktree edit, nevergit stash) turns 7 red / 15 green, with every red inside the two divergence blocks and the zero-write signature quoted in the message:Every control case stayed green under that revert, which is the point — they pin behaviour this change must not move. The working tree was confirmed byte-identical to the pre-revert state afterwards.
Direction stated honestly for one file.
ViewTabBar.setDefaultVisibility.test.tsxis GREEN before the fix and GREEN after.readonlyhiding a mutating entry was already correct — the bug was upstream, in which views got classified as system — so it is a forward-direction control (it exists to catch a later "just force-show the menu item" shortcut), not a red-first pin, and is not claimed as one.Also run:
type-check(app-shell, plugin-view) clean;eslinton all four touched files — 0 errors, and the new files add no new warnings;check:control-bytesOK over 3913 tracked files;changeset:checkOK. Changeset:@object-ui/app-shellpatch. Noskip-changesetlabel (objectui#3724).Out of scope, filed separately
While mapping the seam, the same two-reads-one-namespace structure showed an opposite-direction defect, pre-existing and untouched here:
updateViewConfigstampsobject: objectNameon every personalisation row, andlistViews()narrows by exactly that field, so a system view's override row comes back as a saved-view row. A code-defined view the user has merely personalised therefore reads as user-created and gains rename / delete / set-default. Filed rather than fixed — deciding which rows count as saved views is a separate question from making the two reads agree on identity.Generated by Claude Code