Repository navigation
fix(app-shell): saving a view's config no longer turns the view read-only (objectui#10210) - #10332
Conversation
…d the panel resumes one
"Edit view config -> Save" wrote the flat runtime tab as the view's row. On a
code-defined view the server inherits `viewKind: 'list'` from the registry
entry, and a flat row carrying `viewKind` is the shape the adapter's
legacy-overlay net reads as a personalization overlay: `listViews()` dropped
the row, the tab was stamped read-only, and publishing made it permanent.
The save now writes `{ name, object, viewKind: 'list', label, config }`
through the existing `viewEnvelope()`, keyed by the tab id it always used.
`config` is narrowed to the keys the spec's closed `ListViewSchema` declares
(an envelope whose `config` carried the tab's `id` / `isDefault` is refused
with 422 by the platform's write door), and the row-level keys the switcher's
own handlers write (`isDefault`, `isPinned`, `sortOrder`, `visibility`,
`columnState`) are carried forward at the envelope's top level.
The panel's draft resume read every stored draft as a flat view; an envelope
draft lost its identity there and the next Save persisted nothing.
`storedViewToRuntimeView` reads both stored shapes back to the runtime view.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…e draft resume
- the builder's body is spec-valid (`ViewMetadataSchema`, the platform's `view`
gate), keyed by the tab id, keeps row state and sheds the overlay marker;
- a re-read through the real adapter still lists the view as a saved view, and
a never-saved sibling (the card's control) is unaffected;
- the real `ObjectView` save handler stages the envelope on the tab's row;
- the panel resumes a stored envelope with its identity, and a flat draft saved
before the fix still resumes.
The handler's inline comment is trimmed back so the objectui#4155 ratchet's
"explicit save path" control still finds `persistRuntimeMetadata('view'`
within its window.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
`objectName` comes from `useParams()` and may be undefined; `viewEnvelope` already accepts that. The draft is read as `Record<string, unknown>` and the label is taken only when it is a string. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Rendered by an isolated review subagent spawned by the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…arker only (objectui#10210, ruling B) Retire `isLegacyOverlayRow`, the shape guess that also treated a flat view row carrying `viewKind: 'list'` as a personalization overlay. An "Edit view config -> Save" on a code-defined view stored that same shape before PR #10332, so `listViews()` dropped the user's own view, the tab was stamped read-only, and publishing made it permanent. Under the maintainer's ruling B (objectui#10210, comment 5824008636) the marker is the only discriminant: such rows heal on read with their edits, and the one exposed class (overlay rows written before the marker and never touched since) is named in the changeset and pinned. The pins that asserted the guess are rewritten with the reason, not deleted: `listViews`, `viewOverlayPatchOnly` (and the header of `viewOverlayMarker`), plus the two app-shell pins that ran the real adapter through the same guess. The two pending changesets this makes false are corrected in place. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
Part of #10210
Clause-②: yes
What this changes
handleViewConfigSave(packages/app-shell/src/views/ObjectView.tsx) now persistsbuildViewConfigSaveBody(objectName, draft): a ViewItem envelope{ name, object, viewKind: 'list', label, config }built through the existingviewEnvelope(), addressed to the same row key as before (the tab id).configis the draft narrowed to the keys the spec's closedListViewSchemadeclares; the row-level keys the switcher's own handlers write (isDefault,isPinned,sortOrder) plusvisibilityandcolumnStateride at the envelope's top level, so the whole-document PUT does not erase them.ViewConfigPanelreads a pending draft back through the newstoredViewToRuntimeView(view-config-adapter.ts): an envelope is read back to the runtime view, keyed by its row name; a flat draft stored before this change is returned as it is.@object-ui/data-objectstackis untouched:isLegacyOverlayRowis exactly as onmain(see the fork below).Why
Part ofTriage step 1 (stop the bleed) is done here. Triage step 2, rows an earlier published save already made read-only, is measured below and reported as a fork: no repair rule ships in this PR, and the options with a recommendation are in the dev report on the card for the seat's decision card.
Measured end to end, before and after
Setup: the published
@objectstack/cli17.4.0 (objectstack dev --fresh, empty sqlite) serving a probe app with one object and three code-defined list views (defineViewwithlistpluslistViews.allandlistViews.big); this worktree's consolesrcthrough vite; Playwright on the preinstalled Chromium. Cells are tab-menu entry counts forprobe_item.all.main8b1f066PUT /api/v1/meta/view/probe_item.all?mode=draft, flat body?preview=draftprobe_item.bigThe same run on the default view (
probe_item.default) keepsisDefault: trueacross save, resume, save, publish and a toolbar toggle.Captured save bodies:
main:{"label":"Everything EDITED","type":"grid","columns":[{"field":"subject"}],"name":"probe_item.all","isDefault":false,"id":"probe_item.all"}, served back flat withviewKind: "list"inherited from the code definition.{"name":"probe_item.all","object":"probe_item","viewKind":"list","label":"Everything EDITED","config":{"type":"grid","columns":[{"field":"subject"}],"data":{"provider":"object","object":"probe_item"}},"isDefault":false}The dispatch's four mechanism assumptions
probe_item.all. The envelope'snameis pinned to the tab id verbatim: for anOBJECT.KEYid,viewEnvelope's own re-qualification yields the same string, and the pin keeps the save on its row for any id.[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.Drafts of views created throughhandleViewCreateare envelopes too, so they already hit this. Repaired here; see "Outside the claimed file surface".configcopy the tab carries. That is the same hybrid a toolbar toggle on a pristine code-defined view writes onmaintoday;isLegacyOverlayRowreturns false on it and the tab stays at 6.viewEnvelope(object, draft)as it stands is refused at the platform'sviewwrite gate (ViewMetadataSchema, the schema the spec's metadata-type table assigns toview), measured on the live 17.4.0 door:422 INVALID_METADATA,config [unrecognized_keys], "Unrecognized key(s) on this list view:isDefault,id". Handled by narrowingconfigtoListViewSchema's declared keys;isDefaultsits at the envelope's top level, where the gate accepts it and wherelistViews()reads it.Triage step 2: rows already made read-only — measured, reported as a fork
Each writer's exact request replayed against the same 17.4.0 backend, then read back from
GET /api/v1/meta/view:{label, type, columns, name, isDefault, id, viewKind, object}data: the same keys plusdata,filter,rowHeightdata(the view composer stamps none):{label, type, columns, name, isDefault, id, rowHeight, object, viewKind}No structural key separates the first from the third: both flat,
viewKind: "list", no marker, nodata,idandisDefaulton both. What differs is content (rowHeighthere), which neither population requires or excludes. What does separate them is server-side provenance:GET /api/v1/meta/view/NAME/historyshows apublishevent for the config-saved row and a single directcreate(protocol.saveMetaItem) for the replayed overlay; that is readable per row, not from the list read. So every client-side shape rule trades "an old overlay read as a saved view" against "a broken view stays read-only".Meanwhile, measured:
DELETE /api/v1/meta/view/NAMEresets such a row to its code definition ("reset to artifact default") and drops the saved edits; the tab menu of a broken view offers no route to it. The secondary item (narrow or retire the shape heuristic) is one of the fork's options, so it is not in this PR.Outside the claimed file surface
ViewConfigPanel.tsxandview-config-adapter.tsare not in the claim's file surface. The resume repair rides here because the envelope save would otherwise carry the A2 regression to every edited view, and all four in-place conditions hold: same defect class (the stored view draft's write and read shapes); a mechanical change whose shape is pinned by the envelope type and the create path; no open PR touches either file (27 open PRs' file lists read, the release PR's 1732 files enumerated in full with no source file among them); and the same gate family, app-shell tests. The claim's file surface needs the matching amendment.Tests and gates (all at
bbbba169b, clean tree)pnpm exec vitest run --maxWorkers=2over the 65 test files that import or read the changed modules, plusdata-objectstack'slistViews,viewOverlayMarkerandlistViewOverridessuites:Test Files 65 passed (65),Tests 944 passed (944).ObjectView.viewConfigSaveEnvelope-10210.test.ts(the body passesViewMetadataSchema, keeps the row key and row state, sheds the overlay marker; a re-read through the real adapterlistViews()keeps the saved view and the never-saved control; resume then save is idempotent),ObjectView.viewConfigSaveWiring-10210.test.tsx(the realObjectViewsave handler stages the envelope on the tab's row),ViewConfigPanel.resumeEnvelope-10210.test.tsx(an envelope resumes with its identity; a flat pre-change draft still resumes), andstoredViewToRuntimeViewcases inview-config-adapter.test.ts.turbo run build --filter=@object-ui/app-shell^...(28 of 28 tasks), thenpnpm --filter @object-ui/app-shell type-checkexit 0 (bothtsc --noEmitand the test tsconfig, whose--listFilesOnlyincludes all four new or edited test files) andpnpm --filter @object-ui/data-objectstack type-checkexit 0.ablation-replace(anchor hit once, blob changed, then restored to the HEAD blob withgit diff HEADempty):draft: the wiring pin goes red (1 failed, 6 passed);config: 5 red (the spec refusal, both in the fake write door and in the directViewMetadataSchemacheck).check-changeset-presence,check-changeset-no-major,check-changeset-fixed,check-changeset-overwrite,check:new-line-citations(0 new),check:changeset-claims,check:pending-changeset-literals,check:control-bytes,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:metadata-write-doors,check:spec-symbols,check:test-path-roots,check:phantom-deps.check-governed-queue-guard --testover the diff: NOT GOVERNED.eslint.config.js(whose**/*.{ts,tsx}block covers every touched file) over the 7 touched TypeScript files;--format jsonreports 7 files and 0 errors. The one warning in new non-test code isreact-refresh/only-export-componentson the exported builder, which every sibling exported helper in the file also carries. The config enables no type-aware linting (noparserOptions.project, noprojectService) and its custom rules are per-file, so this diff cannot move an untouched file's verdict. The repo-widepnpm lintis CI's.Acceptance notes
OBJECT.KEY(the synthesizedalltab of an object with no views): the platform refuses both the old flat body (parsed as a view container) and an envelope under a bare name (ViewItemNameSchema). Measured at the protocol level only; the UI path was not driven. Unchanged by this PR.buildPersistedViewBodywithisSavedView: true) still writes the whole tab, flat keys plus the nestedconfigcopy and the registry bookkeeping it carries. It stays clear of the legacy-overlay shape only because the tab carries thatconfig. Unchanged here.#10209(the menu symptoms, seat 3) is not touched here; this PR shares no file with it.Session
https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C(dispatched by the domain:ui execution seat 4).Generated by Claude Code