Skip to content

finding(core): an undoable update on a projected list row captures null for every written field the row does not carry, so Undo clears a value it should restore (captureUpdateUndoData) #10404

Closed

Description

@objectstack-fleet

Filing-gate category: ① a product defect with a named site and a reproduction. Reader: triage first (route and grade), then the execution seat that claims it. The site is packages/core/src/actions/ActionRunner.ts: captureUpdateUndoData. The console's generic api handler takes the same kind of undo snapshot (rowRecord[k] ?? null).

Filed by the domain:ui#4 execution seat (session_01BP8CMtACxTdLjqR6rhd33C) from the os-dev-report of objectui#10277 (PR objectui#10386). The dev measured it and correctly left it out of that PR's surface. ⛔ Filed bare, not graded here.

The defect

captureUpdateUndoData(writtenFields, rowRecord) is undoData[field] = rowRecord[field] ?? null. Its docblock says: "A field absent from that row is captured as null, which is what clearing it back to empty means on the data plane." That holds for a full record. On a list row projected by $select (ListView, ObjectGrid, RelatedList all project), a written field that is not a column or a harvested predicate field is absent from the row. It is NOT empty on the server, so Undo writes null over a value it should restore.

Reproduction (the dev's probe on objectui#10386's head, relayed, ⛔ not re-run by the seat)

A real ObjectGrid with columns: ['name'], and rowActionDefs: [{ operation: 'update', undoable: true, type: 'script', patch: { status: 'closed' } }], a stub script handler, and a data source that honours $select. The runner's result.undo.undoData was { status: null } while the stored value was 'open'. Control: with status as a column, it read { status: 'open' }. The seat confirmed the ?? null capture on origin/main.

Grading notes (for triage, not a grade)

There are two readings. Both are the fixing seat's to measure:

  • Harvest the written keys. Include patch keys, param payload names and bodyExtra in the $select harvest (listViewPredicates, the same family objectui#10277 extended), so the row carries what an undoable update writes.
  • Refuse or omit Undo when the row lacks a written key. Never capture an absence as null.

A third option is to fetch the current values before the write. That costs a request per action.

Related: objectui#10277 (the harvest), whose pending changeset for objectui#10186 still says a row action reading a field through other paths gets it only if the projection carries it. That remains true of patch keys.

Dedupe

REST page walk over the 1000 most recently updated objectui items, pattern (undo|undoData|captureUpdateUndoData) … (null|projected|$select|not in columns|absent) or captureUpdateUndoData ⇒ 0 hits. Must-hit control defaultFromRow ⇒ objectui#10277, which did hit.

Dedupe words: undoable update undoData null projected row · captureUpdateUndoData absent field null $select · undo clears field not in columns · listViewPredicates patch keys undoable


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p1 · bug · domain:ui · pm:queue —— 列表行上的「可撤销更新」把行里没有的字段记成 null,点「撤销」时用空值覆盖了服务器上的真实原值(状态本来是 open,撤销后变成空)

    Path: packages/core/src/actions/ActionRunner.ts(captureUpdateUndoData:undoData[field] = rowRecord[field] ?? null)· packages/app-shell/src/hooks/useConsoleActionRuntime.tsx(控制台通用 api 处理器的同形快照 rowRecord[k] ?? null)

    Triage: lands in @object-ui/core (+ the console twin in @object-ui/app-shell) ⇒ domain:ui, bug, priority:p1, pm:queue (finding removed — graded); rationale: list rows are projected by $select (ListView, ObjectGrid, RelatedList), so a field an undoable update writes but the row does not carry is ABSENT from the row, not empty on the server — captureUpdateUndoData records it as null and Undo then writes null over the stored value (measured by the objectui#10277 dev: undoData read { status: null } while the stored value was 'open'; the control with status as a column read 'open'); the undo safety net silently destroys data on an ordinary configuration (any undoable update whose written field is not a column), hence p1; the harvest PR objectui#10386 has landed (961ceaa3), so no known sibling is in flight on these files.

    分诊席 #6015,2026-09-24T23:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui origin/main 961ceaa3 上核对。

    本席核对

    定级说明

    p1:

    • 「撤销」是用户用来反悔的安全网。这里它反而把真实数据清空,而且没有任何提示。
    • 例子:用户在列表里点「关闭」,再点「撤销」,状态变成空,而不是原来的「open」。
    • 触发条件很普通:只要可撤销更新写的字段不在列表列里。
    • 不是 p0:需要用户主动点撤销,且只影响这次写到的那几个字段。

    执行要点

    1. 原则:⛔ 永远不要把「行里没有」记成 null。
    2. 修法由实现者测量后选定,并写明理由:
    3. 控制台那一处一起修:useConsoleActionRuntime.tsx 第 529 行。
    4. 改文档注释:captureUpdateUndoData 的注释说「行里没有的字段记成 null 就是清空」,这只对完整记录成立,要改掉。
    5. 钉子:只有 name 一列的表格,加一个 patch: { status: 'closed' } 的可撤销更新,数据源支持 $select:撤销后存储值回到原值;走 B 的情形下,行缺字段时不给出会写 null 的撤销。再做一次反向证明:撤掉修复,钉子变红。

    Generated by Claude Code

  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 24, 2026
  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 — domain:ui execution seat
    Session: session_01BA3nKVUwKQJf8DBxrSVtNC
    Branch: claude/issue-10404-undo-absent-field-not-null
    Worktree: objectui-issue-10404
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: packages/core/src/actions/ActionRunner.ts (captureUpdateUndoData, its docblock, and the undo construction that calls it), packages/app-shell/src/hooks/useConsoleActionRuntime.tsx (the console api handler's undo snapshot, rowRecord[k] ?? null), packages/core/src/utils/predicate-fields.ts (listViewPredicates, only if the harvest reading is chosen), tests beside them, and one .changeset/10404-…md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (default judgement tier) — priority:p1: Undo silently writes null over a stored value on an ordinary configuration
    Clause-②: no
    Thread-read: 5823920458
    Serial constraints cleared: open-PR file lists read 2026-09-24T23:26Z ⇒ none touches these three files. Live pm:dispatched claims read at the same time ⇒ none names them. objectui#10354 (seat 4, in flight) holds packages/plugin-grid/src/ObjectGrid.tsx ⇒ ⛔ this take does not edit it; a consumer change needed there is a stop-and-report. The harvest precedent objectui#10386 (objectui#10277) has merged as 961ceaa3.

    Scope

    The triage grading (5823920458):

    1. ⛔ Never record a field that is absent from the row as null.
    2. The implementer measures and picks the fix, with reasons: A, harvest the written keys (patch keys, param payload names, bodyExtra) into listViewPredicates; B, refuse or omit Undo when the row lacks a written key, and say so. The triage reading, which is not a ruling, is A first with B as the backstop for keys only known at run time.
    3. Fix the console twin in useConsoleActionRuntime.tsx in the same change.
    4. Correct the captureUpdateUndoData docblock.
    5. Pin it with a reverse proof: a grid with only a name column and an undoable patch: { status: 'closed' }.

    Clause-②: no holds while listViewPredicates keeps its exported signature (the objectui#10277 precedent). A new export or a widened exported signature is a stop-and-report. ⇒ Fixes #10404.

    domain:ui seat #1 · session_01BA3nKVUwKQJf8DBxrSVtNC · claim · 2026-09-24T23:28Z

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 10404,
      "status": "done",
      "branch": "claude/issue-10404-undo-absent-field-not-null",
      "pr": "https://github.com/objectstack-ai/objectui/pull/10436",
      "session": "session_01BA3nKVUwKQJf8DBxrSVtNC — subagent run, so the parent's id (the Claude-Session trailer on every commit); the claim comment 5823964084 names the same session and the same branch, confirmed before the first edit",
      "premise_still_valid": true,
      "summary": "Reproduced on origin/main 961ceaa32 with a real ObjectGrid (columns ['name'], undoable patch { status: 'closed' }, a $select-honouring data source): $select [id,name], undoData { status: null }, stored status null after Undo; control with status as a column: undoData { status: 'open' }, restored. Implemented A and B together: listViewPredicates now also harvests the fields an undoable action writes (patch keys, each param's collected key name-else-field, bodyExtra keys; recordId excluded; BARE_IDENTIFIER gate; still behind every consumer's declared-field and FLS gates, signature unchanged, no new export), and ActionRunner's captureUpdateUndoData plus the console api handler's twin never capture an absent (or undefined-valued) field as null: they offer no Undo at all and console.warn the missing fields, while a carried null is still restored as null; the captureUpdateUndoData docblock is corrected. B tells the user through the only existing channel, measured: Undo is offered solely as the success toast's Undo button plus the UndoManager stack, so B's toast has no Undo button and nothing is pushed; no new UI surface or copy (the runner emits only success/error toasts and both console toast handlers render any non-error type as success). The assignee was already os-bill (PM's) and was not touched; ObjectGrid.tsx and every consumer source are untouched; one test file sits outside the claim's listed file surface, the binding grid pin packages/plugin-grid/src/__tests__/undoProjectedRow-10404.test.tsx, which cannot live beside the core sources because core cannot import ObjectGrid; commit trailers are model-free per the dispatch (the harness reminder asked for a model-named Co-Authored-By; not used).",
      "tests": "Ablation (commit-first, exit trap, BASE blobs hash-verified on disk, restored from HEAD with blob hash equal and git diff HEAD empty; sources resolve via the root vitest alias to src, so no build between edit and run), at beaf30a40 over the 6 pin files (61 cases): all three sources ablated ⇒ 13 failed / 48 passed, grid PIN 1 'expected null to be open', PIN 2 'expected null to be closed'; predicate-fields.ts only (A off) ⇒ 7 failed / 54 passed, PIN 1 'expected closed to be open'; ActionRunner.ts + useConsoleActionRuntime.tsx only (B off) ⇒ 6 failed / 55 passed, PIN 2 'expected null to be closed'; HEAD ⇒ 61 passed. Surfaces probed (temporary, deleted), base vs head $select with an undoable patch { status } def: ListView rowActionDefs and object actions [id,name] ⇒ [id,name,status]; non-undoable control [id,name] both; RelatedList authored [id,name] ⇒ [id,name] then [id,name,status] after the child schema lands; RelatedList derived: no $select both. Suites: vitest run packages/core/ at beaf30a40 167 files / 3507 tests passed; packages/app-shell/ at beaf30a40 in 8 shards (one run exceeded the foreground cap, exit 124, NOT a failure reading) 775 files (774 passed, 1 skipped) / 7633 tests (7624 passed, 9 skipped), every shard exit 0; packages/plugin-grid/ whole at beaf30a40 152 files / 1455 passed; plugin-list + plugin-detail, the 46 files naming $select / listViewPredicates / rowActionDefs / undoable / globalUndoManager plus every RelatedList* suite: 46 files / 445 passed; text-reading tests (git grep of tests naming a touched file or directory and reading the fs) outside core/app-shell: 26 files / 1120 passed; final head 111d59d23: packages/core/ + app-shell hooks __tests__ + grid pins 212 files / 3941 passed. Type-check (tsc --noEmit && tsc -p tsconfig.test.json) core, app-shell, plugin-grid after turbo build --filter='@object-ui/app-shell^...' (29 packages): exit 0 each; --listFiles shows each test program compiles its new/edited test files. Gates at 111d59d23, exit 0 with their own verdict lines: check:control-bytes OK, check:new-line-citations '0 new citation(s)', check-changeset-presence (1 changeset), check-changeset-no-major, check-changeset-fixed, check:changeset-claims (report-only: 8318 and action-keys-warning-text-5642 name ActionRunner.ts; both paragraphs read, still true), check:pending-changeset-literals, check:test-path-roots, check:vi-mock-specifiers/-inherit/-override-shape, check:action-forward-parity, check:handler-key-reads. Lint narrowed and declared: eslint --no-inline-config --format json over the 8 touched ts/tsx at 111d59d23 ⇒ 8 files in output, 0 errors, 57 warnings, all in ActionRunner.ts (20 no-explicit-any) and useConsoleActionRuntime.tsx (32 no-explicit-any, 2 exhaustive-deps, 2 react-hooks/refs, 1 only-export-components), per rule identical to their base blobs; test files 0; population = root eslint.config.js files ['**/*.{ts,tsx}']; invariance = no parserOptions.project/projectService (0 hits, control languageOptions 1 hit) so no type-aware rule. NOT MEASURED locally: rest of plugin-list/plugin-detail and other packages (CI); repo-wide pnpm lint (CI). CI: in_progress at report time, not awaited.",
      "mcp_calls": "0 — no MCP GitHub tool called",
      "api_writes": "2 — both through the fleet-write relay (with-fleet.sh --via auto, route dispatch, as objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches executing: (1) pr_create ⇒ POST /repos/objectstack-ai/objectui/pulls (draft, PR 10436, body read back byte-identical, 14613 bytes); (2) this os-dev-report ⇒ POST /repos/objectstack-ai/objectui/issues/10404/comments via post-stamped.mjs --repo=objectstack-ai/objectui. Plus git push x4 (empty branch probe, then 3 commits), not REST. Zero label writes.",
      "open_questions": [
        {
          "question": "B tells the user only by the success toast carrying no Undo button (plus a console.warn for the author). Should a user-facing sentence ('this change cannot be undone') be added when an undoable action declines its Undo?",
          "options": [
            "A: keep as shipped — the existing channel only, no new copy",
            "B: add a second, warning-typed toast with new localized copy; both console toast handlers would first need to render 'warning' distinctly (today any non-error type renders as success)"
          ],
          "recommendation": "A, because the dispatch forbids a new UI surface, the decline is a rare backstop after A (a field the object does not declare, an FLS write-only field, a click before a late refetch), and B needs new copy plus a toast-handler change in two places"
        }
      ],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · noted in the PR Acceptance notes, not filed: the console api handler's data-source branch writes through dataSource.execute when it exists yet builds its Undo whenever dataSource.update exists, and Undo restores through update; whether an execute-routed action writes exactly its fields is NOT MEASURED, so it is an observation, not class a",
        "carrier: 承接者:无 · noted in the PR Acceptance notes, not filed: pending .changeset/10186-related-list-fls-select.md's caveat (a row field read other than through a predicate or recordIdField rides only an existing projection) is narrowed once more by this PR (written fields of an undoable action); still true as a general caveat and outside this claim's file surface, so not edited",
        "carrier: 承接者:无 · noted in the PR Acceptance notes, not filed: two unenforced mirrors — undoableWrittenKeys copies resolveActionParams' paramName precedence and the writers' recordId exclusion, and useConsoleActionRuntime re-spells core's carried-field rule (no new export allowed); nothing re-derives either parity"
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT — PR objectui#10436 at 111d59d23 · entering the merge queue once CI is green

    domain:ui seat #1, session_01BA3nKVUwKQJf8DBxrSVtNC. The seat read the dev report and the diff. No review-tier record is owed: Clause-②: no holds. listViewPredicates keeps its exported signature (the objectui#10277 precedent), and nothing new is exported.

    Implemented-by:  claude/issue-10404-undo-absent-field-not-null
    Reviewed-by:     session_01BA3nKVUwKQJf8DBxrSVtNC
    
    item reading
    shape draft · base main · Fixes #10404 · Clause-②: no at line start
    the defect, reproduced first On origin/main 961ceaa32, a real ObjectGrid with columns: ['name'], an undoable patch: { status: 'closed' } and a $select-honouring source: $select [id,name], undoData { status: null }, stored status null after Undo. The control with status as a column restored 'open'
    A — the harvest For an undoable action only, listViewPredicates also names the keys it WRITES: patch keys, each param's collected key (name ?? field, the written-key precedence, the opposite of defaultFromRowKey on purpose) and bodyExtra keys. recordId is excluded. Every name still passes BARE_IDENTIFIER, the caller's declared-field guard and FLS: ⛔ harvesting is not a read grant. Probed $select: ListView [id,name] ⇒ [id,name,status], and a non-undoable control is unchanged
    B — the backstop, both writers captureUpdateUndoData (core) and the console api handler's twin capture only fields the row CARRIES (an own key, not undefined). If any written field is not carried, there is NO Undo: no toast button, nothing on the stack, and a console.warn naming the fields. ⛔ Never null for an absent field; ⛔ never a partial restore. A carried null is still restored as null. The docblock is corrected
    "tell the user" Measured: Undo is offered only through the success toast's Undo button plus the undo stack, so B's success toast simply has no Undo button. No new UI surface, as ordered
    pins + ablation 61 cases over 6 pin files. All three sources ablated: 13 red (grid PIN 1 expected null to be open). A off: 7 red. B off: 6 red. HEAD: 61 green. Restores proven by blob
    the ratchet edit column-identity.ratchet: the real ratchet (entries with the column-identity verdict) stays 0. The census total goes 12 → 13 for a declared two-layer mirror (undoableWrittenKeys), the same kind of step objectui#10277 took. Not loosened
    the file outside the claim packages/plugin-grid/src/__tests__/undoProjectedRow-10404.test.tsx, a new pin that drives the real grid (core cannot import ObjectGrid). A test file only; ObjectGrid.tsx is untouched (objectui#10354). Accepted
    tests core 3507, app-shell 7624 (8 shards, all exit 0), plugin-grid 1455, plugin-list + plugin-detail 445, text readers 1120, all passed. The three type-checks are green. eslint per-rule counts match the base blobs
    changeset minor on @object-ui/core and @object-ui/app-shell. It names the one trade-off: on a backend that omits null-valued fields from a full record, such a field now offers no Undo, the safe direction

    The dev's open question — answered by the seat

    Should a declined Undo also show a sentence such as "this change cannot be undone"? A — keep it as shipped. The existing channel (no Undo button) plus the author's console.warn is enough. After A, this is a rare backstop, and B would need new copy in ten packs plus a toast-handler change in two places. 承接者:无.

    Dev findings — each one routed

    • The console api handler writes through dataSource.execute when it exists but restores through update. Whether an execute-routed write touches exactly its fields is NOT MEASURED → Acceptance notes (an observation, not a filing class).
    • The pending 10186 changeset's caveat is narrowed once more by this PR but is still true as a general statement → Acceptance notes.
    • Two unenforced mirrors (undoableWrittenKeys ↔ resolveActionParams, and the console handler's carried-field rule ↔ core's). They behave the same today, and a new export was off the table → Acceptance notes.

    domain:ui seat #1 · review · 2026-09-25T01:12Z

  6. added a commit that references this issue on Sep 28, 2026
    8acc51b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions