…the row did not carry
An undoable update captured the prior value of each written field off the
row it ran on as `rowRecord[field] ?? null`. On a list row projected by
$select, a written field no column shows is absent, so it was captured as
null and Undo wrote null over the stored value (objectui#10404).
- listViewPredicates also harvests the fields an undoable action writes:
patch keys, each param's collected key (name, else field) and bodyExtra
keys, through the existing identifier, declared-field and FLS gates.
- ActionRunner's operation:update capture and the console api handler's
data-source branch capture only fields the row carries (own key, value
not undefined); otherwise they offer no Undo and warn with the missing
fields. A carried null is still restored as null.
- The column-identity ratchet records the harvest's new name-first read.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Fixes #10404
Clause-②: no
What was wrong
An
undoableupdate records the prior value of each field it writes, read off the row the surface stashed underparams._rowRecord, so the success toast can offer Undo.captureUpdateUndoDatain@object-ui/core'sActionRunnerread it asrowRecord[field] ?? null, and the consoleapihandler's data-source branch (useConsoleActionRuntime) did the same. A list row is projected by$select, so a written field no column shows is ABSENT from the row while the server holds a real value. It was captured asnull, and Undo wrotenullover the value it existed to restore.Measured first, on
origin/main=961ceaa32Temporary probe, deleted before commit: a real
ObjectGridwith columns['name']androwActionDefs: [{ operation: 'update', undoable: true, type: 'script', patch: { status: 'closed' } }], ascripthandler that writes what it is sent, a data source that honours$select, the stored row{ id: 't_1', name: 'Ada', status: 'open' }, and Undo applied the wayuseGlobalUndoapplies it.$selectundoDatastatusafter Undoname["id","name"]{ status: null }nullname,status["id","name","status"]{ status: 'open' }'open'The premise holds.
The fix: A and B together
The triage reading was A first, with B as the backstop. Measured, both halves are needed, and each one fails its own pin without the other (ablation below).
listViewPredicatesnow also names the fields anundoableaction writes, spelledrecord.KEYlike its other non-predicate entries: thepatchkeys, the key each param's value is collected under (name, elsefield), and thebodyExtrakeys.recordIdis left out, since both writers strip it as the record's address. The rule is narrowed toundoable, and not for cost: the Undo capture is the only reader of a written field's prior value, and it runs only underundoable. So a projected row onObjectGrid,ListViewandRelatedListnow carries what its Undo reads, with no consumer source change.null. "Carries" means an own key whose value is notundefined(JSON cannot sendundefined, so such a value says nothing about what is stored). If any written field is not carried, the action still runs but offers no Undo at all: a partial Undo reported as a full one is worse than none, the rule the docblock already stated. Anullthe row does carry is a real empty value and is still captured asnull. The same rule is applied to the consoleapihandler's written set (paramsplusbodyExtra).The card's third option, reading the current values before the write, was not taken: it costs a request per action, and A plus B leave nothing for it to cover.
Dispatch mechanism assumptions, measured
captureUpdateUndoDatawasundoData[field] = rowRecord[field] ?? null, called fromexecuteUpdateOperation;writtenFieldsis the keys of{ ...patch, ...collected }minus_rowRecordandrecordId. The probe above printedundoData{ status: null }andredoData{ status: 'closed' }.git grepfinds exactly three callers oflistViewPredicates:ObjectGrid,ListView,RelatedList. Temporary probes (deleted) with an undoablepatch: { status }def, the base harvest against the head harvest:$select$selectObjectGrid,rowActionDefs["id","name"]["id","name","status"]ListView,rowActionDefs["id","name"]["id","name","status"]ListView, objectactions["id","name"]["id","name","status"]ListView, the same def withoutundoable(control)["id","name"]["id","name"]RelatedList, authoredcolumns, hostrowActionsor object actions["id","name"]["id","name"], then["id","name","status"]once the child schema landsRelatedList, derived columns$select$selectOther
_rowRecordstashes call no harvest:DeclaredActionsBarand the record-page header actions hand the page's full record;ObjectGrid's bulk runner suppresses the toast, so no Undo is ever pushed there.4. What A cannot cover, and B holds for: a written key the object does not declare; a declared field the principal may write but not read (the FLS gate drops it, as it must, since harvesting is not a read grant; PIN 2 below); a key that is not a bare identifier; a key only known at run time, such as a value-bag
paramsa host spreads in (the record-page header does this for a non-arrayparams); a row clicked before a late refetch (theRelatedListfirst fetch above); rows a host supplies itself. On every one of them there is no Undo, never anull. The card's "param payload names" are not run-time only: a param's value is collected under itsname, else itsfield(paramNameinresolveActionParams), which the def declares, so A harvests it.5. Full records. The record page and
RelatedList's derived path hand a full record. On ObjectStack a full record carries every readable column, withnullfor an empty one, and B still captures a carriednull(pinned), so those surfaces are not regressed. The one case B changes there is a backend that leaves null-valued keys out of a record: absent and empty cannot be told apart, and B takes the side that cannot overwrite data. The changeset says so. Read, not proven: agit grepof objectstack'spackages/*/srcfor null-stripping of returned rows found none.6. How Undo is offered today: only as the success toast's Undo button.
handlePostExecutionpassesundoto the toast handler, and pushes ontoglobalUndoManager, only whenresult.undois set; Ctrl+Z runs that same stack. Withoutresult.undothe success toast shows with no button. B uses exactly that existing channel: no Undo button, nothing pushed, so Ctrl+Z cannot reach it either. No new UI surface and no new copy. The cause goes to the author as aconsole.warnnaming the missing fields. A separate user-facing "cannot be undone" toast was not added: the runner emits onlysuccessanderrortoasts today, and both console toast handlers render any non-errortype as a success toast, so no distinct channel for it exists.Card constraints kept
packages/plugin-grid/src/ObjectGrid.tsxis untouched (held by objectui#10354); no consumer source changes at all.listViewPredicates's signature is unchanged, and no package gains an export. The console twin spells its own carried-field check instead of importing one from core.BARE_IDENTIFIERgates every new harvested key.@object-ui/coreand@object-ui/app-shell,minor.Pins, and the ablation that shows each can fail
New:
packages/plugin-grid/src/__tests__/undoProjectedRow-10404.test.tsx, the card's pin. A realObjectGrid, a real kebab click, the realActionRunnerbehindActionProvider, and Undo through the realuseGlobalUndoexecutor, on a data source that honours$select. PIN 1: columns['name'],patch: { status: 'closed' }; after Undo the stored value is'open'again. PIN 2:statusis writable but not readable, so the row cannot carry it; the toast has no Undo, nothing is pushed, and running Undo writes nothing.packages/core/src/actions/__tests__/ActionRunner.undoAbsentField-10404.test.ts, 5 cases: an absent field gives no Undo, a success toast without Undo and a warning naming the field; the control with the field; a carriednullstill captured; an own key holdingundefinednot carried; no partial Undo.packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.undoAbsentField-10404.test.tsx, the console twin: 4 cases of the same shape overparamsplusbodyExtra.packages/core/src/utils/__tests__/predicate-fields.test.ts, 6 cases in a newdescribe:patchkeys; nothing when notundoable; each param's collected key;bodyExtrakeys;recordIdand non-identifiers left out; all three action lists and malformed bags. Each negative case carries a same-fixture positive control.Ablation, commit-first, under an exit trap: the named sources checked out from
961ceaa32and hash-verified equal to the base blob, the pins run, then restored fromHEAD, hash-verified equal to the HEAD blob, withgit diff HEADempty. Run onbeaf30a40; the only later commit retypes two of the new test files.expected null to be 'open'; PIN 2expected null to be 'closed'predicate-fields.tsonly (A off, B on)expected 'closed' to be 'open': B alone restores nothingActionRunner.tsanduseConsoleActionRuntime.tsx(B off, A on)expected null to be 'closed': A alone cannot cover a field it may not ask forEvery failing case is a new case, or the ratchet row this PR raised (red in the two legs that remove the new read). The existing
ActionRunner.updateOperationandpredicate-fieldscases in the same 61 stay green in every leg.One edit worth naming
packages/core/src/utils/__tests__/column-identity.ratchet.test.ts. The objectui#3104 dual-read scanner counts the newname ?? fieldread inundoableWrittenKeys. It is the param-name half ofresolveActionParams' two-layer pair (paramName), the mirror of the row-key half objectui#10277 recorded in the same row, so it is recorded astwo-layerwith its reason: file count 1 to 2, total 12 to 13, section header 6 to 7.columnIdentity()was not used: it also readsfieldNameand treats''as absent.Local verification
pnpm exec vitest run packages/core/atbeaf30a40:Test Files 167 passed (167),Tests 3507 passed (3507).packages/app-shell/atbeaf30a40, in 8 shards (--shard=K/8; the whole package in one run exceeded the foreground cap): 775 files (774 passed, 1 skipped), 7633 tests (7624 passed, 9 skipped), every shard exit 0.packages/plugin-grid/whole package atbeaf30a40:Test Files 152 passed (152),Tests 1455 passed (1455).plugin-listandplugin-detail: the 46 test files there that name$select,listViewPredicates,rowActionDefs,undoableorglobalUndoManager, plus everyRelatedList*suite, atbeaf30a40: 46 files, 445 tests passed.git grepof test files naming a touched file or its directory and reading the filesystem): the core and app-shell ones ran in their packages; the other 26 (packages/types,scripts/__tests__,apps/console,data-objectstack,layout,plugin-detail) atbeaf30a40: 26 files, 1120 tests passed.111d59d23:packages/core/pluspackages/app-shell/src/hooks/__tests__/plus the grid pins:Test Files 212 passed (212),Tests 3941 passed (3941).tsc --noEmit && tsc -p tsconfig.test.json) of@object-ui/core,@object-ui/app-shelland@object-ui/plugin-grid, afterturbo run build --filter='@object-ui/app-shell^...'(29 packages, core included): green, and--listFilesshows each test program compiles its new or edited test file. The app-shell and plugin-grid test programs re-checked on the final test files.111d59d23, each gate's own verdict line read:check:control-bytes,check:new-line-citations(0 new citation(s)),check-changeset-presence,check-changeset-no-major,check-changeset-fixed,check:changeset-claims,check:pending-changeset-literals,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:action-forward-parity,check:handler-key-reads.eslint --no-inline-config --format jsonover the 8 touched.ts/.tsxfiles at111d59d23: 8 files in the output, 0 errors, 57 warnings, all in the two modified sources and per rule identical to their base blobs (ActionRunner.ts: 20no-explicit-any;useConsoleActionRuntime.tsx: 32no-explicit-any, 2react-hooks/exhaustive-deps, 2react-hooks/refs, 1react-refresh/only-export-components); the six test files: 0. Population: the rooteslint.config.jsblockfiles: ['**/*.{ts,tsx}']. Invariance: the config sets noparserOptions.projectorprojectService(0 hits), so there is no type-aware linting and this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.plugin-listandplugin-detail, and every other package. CI runs them.Acceptance notes
.changeset/10186-related-list-fls-select.mdsays a row action that reads a row field other than through a predicate orrecordIdFieldgets it only if the projection already carries it, and a note there says objectui#10277 narrowed that. This PR narrows it once more (the written fields of anundoableaction). The paragraph still holds as a general caveat; it is outside this claim's file surface and was not edited.check:changeset-claimsalso names two pending changesets that mentionActionRunner.ts(8318-jsdoc-default-tag-corrections,action-keys-warning-text-5642); both paragraphs were read and stay true.undoableWrittenKeyscopiesparamName's precedence and the writers'recordIdexclusion, and the console handler spells the core capture's carried-field rule a second time (no new export was allowed). Nothing re-derives either parity.apihandler's data-source branch writes throughdataSource.executewhen the data source has one, yet builds its Undo wheneverdataSource.updateexists, and Undo restores throughupdate. Whether anexecute-routed action writes exactly itsfieldsis not known here.Session:
https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC(thedomain:ui#1seat's dispatch).Generated by Claude Code