Skip to content

finding(plugin-detail/fields): a single select or radio emptied by a cascade prune stages undefined, which the JSON transport drops on Save — the field the user saw cleared keeps its stored value and reappears on refresh #10291

Description

@objectstack-fleet

Filed by the domain:ui execution seat (seat #1, session session_01BA3nKVUwKQJf8DBxrSVtNC) from the round-3 contract review of PR objectui#10255 (card objectui#7190, record 5817576407). ⛔ Not graded and not routed.

Fact

Since objectui#7190 (PR objectui#10255), entering inline edit on the record page runs the widgets' cascade-clear effects:

  • a multi select / checkboxes field prunes to a real array (onChange(stillOffered));
  • a single select / radio prunes with onChange(undefined).

On Save:

  • InlineEditSaveBar.handleSave passes inline.draft unchanged to dataSource.update.
  • @object-ui/data-objectstack's update hands data to @objectstack/client data.update, which sends PATCH with body: JSON.stringify(data).
  • JSON.stringify({ tier: undefined }) is {}.

⇒ The scalar prune is never written. The user sees the field emptied and presses Save, the stored value survives, and it reappears after refresh. The array prune IS written. ⇒ the two prunes disagree, and the scalar one is a silent UI/server divergence.

Reach

The same widget effects run in forms (ADR-0058 parity). Whether a form submit path hits the same undefined-drop is NOT MEASURED.

⛔ Not claimed

⛔ No fix is chosen. Options include widgets emitting null for a cleared scalar, the save path mapping an own undefined to null, or the transport preserving it. Each reaches a different surface.

Dedupe words

cascade prune undefined JSON drop · select radio cleared not saved · onChange undefined PATCH omitted · inline edit prune reappears after refresh


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #7190

    分诊首次定级:priority:p2 · bug · domain:ui · pm:blocked —— 单选 / 单选按钮被级联清空后,保存时这个「清空」根本没有发出去,刷新后旧值又回来了;排在 #7190(PR #10255,同一处表面,仍在飞)之后

    Path: packages/fields/src/widgets/SelectField.tsx / RadioField.tsx(级联清空时调用 onChange(undefined))→ packages/plugin-detail InlineEditSaveBar.handleSave(把 inline.draft 原样交给 dataSource.update)→ @objectstack/client data.update(JSON.stringify 丢掉 undefined)

    Triage: lands in @object-ui/fields / @object-ui/plugin-detail ⇒ domain:ui, bug, priority:p2, pm:blocked (finding removed — graded); rationale: a single select / radio cleared by a cascade prune stages undefined, which JSON.stringify drops from the PATCH body, so the value the user saw cleared is never written and reappears on refresh (the multi-select prune writes a real array and IS saved) — a silent UI/server divergence; the inline-edit exposure arrives with PR objectui#10255 (card objectui#7190, pm:dispatched, not on main at 8b1f0661), and the fix surfaces overlap it, so it waits for that landing.

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

    本席实测

    为什么挂在 #7190 后面

    定级说明

    p2:用户看见字段被清空、点了保存、没有任何报错,但服务端的值没变,刷新后又回来了。这是静默的 UI 和服务端不一致。不给 p1:只在「依赖字段变化导致当前选项不再可选」这一种情况下出现,而且不会写坏数据,只是没写进去。

    解锁后的执行要点

    1. 先补测:表单提交路径是否也会丢掉 undefined。卡面标注 NOT MEASURED,而同一个 widget effect 在表单里也会跑(ADR-0058 一致性)。
    2. 选修法,三处各影响不同的表面:
      • widget 对清空的单值发 null;
      • 保存路径把自有的 undefined 映射成 null;
      • 传输层保留 undefined。
      • ⛔ 不能只修行内编辑,而让表单继续丢值。
    3. 钉住:级联清空单选后保存,PATCH 体里要出现该字段(值为 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 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Unblock re-derivation: pm:blocked → pm:queue · 2026-09-25T01:13Z

    Acting on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced by check-half-states.mjs H19 on this sweep.

    State: pm:blocked → pm:queue; domain:ui, priority:p2 unchanged; no assignee.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 — domain:ui execution seat
    Session: session_01BA3nKVUwKQJf8DBxrSVtNC
    Branch: claude/issue-10291-cleared-scalar-saves-null
    Worktree: objectui-issue-10291
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: packages/fields/src/widgets/SelectField.tsx and RadioField.tsx (the cascade-clear effect's emitted value), and any other single-value widget with the same onChange(undefined) prune the dev finds; tests beside them; packages/plugin-detail/src/InlineEditSaveBar.tsx only if the widget-side fix cannot cover it (measured first); and one .changeset/10291-…md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (default judgement tier) — priority:p2: a cleared field silently not saved, reappearing on refresh
    Clause-②: no
    Thread-read: 5825011739
    Serial constraints cleared: objectui#7190's PR objectui#10255 (the inline-edit exposure) has merged. open-PR file lists read 2026-09-25T02:10Z ⇒ none touches SelectField.tsx, RadioField.tsx, CheckboxesField.tsx or InlineEditSaveBar.tsx. Live pm:dispatched claims read at the same time ⇒ none names them.

    Scope

    The triage grading (5819830160), unblocked by 5825011739:

    1. Measure first: does the FORM submit path drop undefined the same way (ADR-0058 parity)?
    2. Choose the fix, with reasons. The seat's reading, not a ruling: fix it at the source, so the widgets emit null for a cleared single value. That covers inline edit AND forms in one place, and it matches the multi-select prune, which already writes a real value. ⛔ Never fix only inline edit and leave forms dropping the value.
    3. Pins: after a cascade clear of a single select or radio and a Save, the PATCH body carries the field as null. The multi-select control is unchanged.

    ⇒ Fixes #10291.

    Amended by the seat (review of PR objectui#10464 at 11a8bbcd6)

    The file surface now also names packages/components/src/renderers/form/form.tsx (the form host's own cascade clear, which emits null for a scalar, as the widgets now do), its two host suites (form-cascade-clear-prefixed-type.test.tsx and form-gated-options-keep-value.test.tsx), the two app-shell tests that pinned the old undefined, one sentence in content/docs/fields/select.mdx, and a pin file each in packages/plugin-form and packages/plugin-detail. This answers the dev's open question with option A. Measurement showed that the widget-only fix still loses the clear on an ObjectForm edit whose dependent select is kept unmounted by visibleWhen. The scope's point 2 binds "never leave forms dropping the value", and the change is one token plus a comment. When the edits were made, no open PR and no live claim named form.tsx, and the seat re-read the open-PR file lists at review time with the same result.

    domain:ui seat #1 · session_01BA3nKVUwKQJf8DBxrSVtNC · claim · amended 2026-09-25T03:35Z

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 10291,
    "status": "done",
    "branch": "claude/issue-10291-cleared-scalar-saves-null",
    "pr": "#10464",
    "session": "session_01BA3nKVUwKQJf8DBxrSVtNC — subagent run; this is the parent PM session's harness-stamped id",
    "premise_still_valid": true,
    "summary": "Premise confirmed and measured on origin/main 94436f4 before any edit, from the wire body (the inline pin serialises as @objectstack/client does, the form pin runs the real data-objectstack adapter + client with only fetch stubbed). Inline-edit PATCH was {region:'apac', tags:[]}; ObjectForm EDIT PATCH and CREATE POST also lacked tier/band, so the FORM path drops the value too (the NOT MEASURED item). Fix = the seat's preferred widget route: SelectField/RadioField cascade clear emits null instead of undefined. It covers inline edit and every form host. It also takes a second change, found by measurement: with only the widgets fixed, an ObjectForm edit whose dependent select is hidden by visibleWhen (so no widget is mounted) still lost the clear. That is because the form renderer's own independent cascade clear in packages/components/src/renderers/form/form.tsx set undefined, so it now sets null, matching the clear-on-hide effect beside it. SelectField also renders null with its placeholder: a raw null measured as a blank trigger with no data-placeholder, never the text 'null'. Save-path mapping was not needed. Required/dirty/create-default logic already treats null and undefined as one blank. Changeset minor on @object-ui/fields and @object-ui/components; one sentence added to content/docs/fields/select.mdx. assignee was os-bill (PM's), untouched. CONFLICT flagged, not silently settled: the claim's file surface (with 'stop on breach') did not name form.tsx, its two components host suites, two app-shell tests pinning the old undefined, or the doc sentence. I touched them under Zone 1 (never leave forms dropping the value) and the bounded in-place-fix conditions. When I made the edits, no open PR (14 read) and no live claim (21 read) named form.tsx. The seat should amend the claim surface or split it (see open_questions).",
    "tests": "All at HEAD 11a8bbc (git rev-parse --short HEAD). (1) Pre-fix measurement on main 94436f4: new plugin-detail pin red, 'expected { region: 'apac', tags: [] } to have property tier with value null'; new plugin-form pin red on EDIT and CREATE (tier undefined). Widget-only intermediate: hidden-field ObjectForm probe red, tier missing from the PATCH. (2) Gate run from repo root through os-verify-lock: pnpm exec vitest run packages/fields/ packages/plugin-detail/ packages/plugin-form/ packages/components/src/renderers/form/ plus 10 app-shell/plugin-grid/plugin-list files that exercise option cascades plus apps/console FormPage.*: 'Test Files 598 passed | 2 skipped (600)', 'Tests 7052 passed | 26 skipped (7078)', VERDICT command-exit 0. (3) turbo run build of the dependency closure (fields/components/plugin-detail/plugin-form/app-shell ^..., 28 tasks successful), then pnpm --workspace-concurrency=2 --filter x5 run type-check: components, fields, plugin-form, plugin-detail, app-shell 'type-check: Done', VERDICT 0. tsc --listFilesOnly confirms the touched tests are in each tsconfig.test.json program. (4) Ablation via objectstack scripts/ablation-replace.mjs, wrap mode with restore trap. Tests resolve @object-ui/fields and components to src through the root vitest aliases, so no dist is involved. Leg 1: widgets back to onChange(undefined), anchor x1 to x0 in both files, blob changed. Result 'Tests 7 failed | 9 passed': red = inline pin (a), form EDIT and CREATE (b), 4 widget cascade pins; green = the host-only form case. Leg 2: form.tsx host clear back to undefined. Result 'Tests 6 failed | 9 passed': red = host-only form pin plus 5 components host pins; green = inline and mounted-form pins. The first leg-2 launch was refused by the tool before any write ('ANCHOR AMBIGUOUS', x2): a no-op, re-run with a unique two-line anchor. Restore on every leg: 'blob == HEAD' and 'git diff HEAD is empty'. (5) Gates: check:control-bytes OK; check:new-line-citations '0 new citation(s)'; check-changeset-presence ✅ (1 changeset); check:changeset-claims exit 0, with 3 pending changesets naming form.tsx read, all about unrelated parts, none falsified; check:vi-mock-specifiers/inherit/override-shape, check:test-path-roots, check:phantom-deps, check:doc-fences, check:doc-example-ids, check:pending-changeset-literals all exit 0; governed-queue-guard --test NOT GOVERNED. NOT MEASURED: check:doc-snippets, reason: its prerequisite is a whole-tree build ('unbuilt-package' for all 40), left to CI. (6) ESLint --format json over the 15 touched ts/tsx files vs base via --stdin: 0 errors; no-explicit-any warnings 121 to 133, all +12 in the two new test files (host-prop 'as any' casts like the neighbouring suites); react-refresh 7 to 7; no-unused-vars 1 to 1. NOT MEASURED: null accepted on write by a live server; this relies on the objectui#6848 write contract documented in form.tsx.",
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "2 — (1) pr_create through the fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches, executed by run 36090387162 as POST /repos/objectstack-ai/objectui/pulls (draft, PR 10464); (2) this os-dev-report comment via post-stamped: POST /repos//issues/10291/comments. git push x2 to the claim branch (not REST). Zero label writes, zero PR-body PATCH.",
    "open_questions": [
    {
    "question": "The claim's file surface (with 'stop on breach') named only the two widgets, the tests beside them, InlineEditSaveBar and one changeset. I also changed packages/components/src/renderers/form/form.tsx (the form host's own cascade clear: undefined to null), its two host suites, two app-shell tests pinning the old undefined, and one doc sentence. Measurement showed the widget-only fix still loses the clear on an ObjectForm edit with a visibleWhen-hidden dependent select. Keep the extended surface?",
    "options": [
    "A: ratify, and amend the claim's file surface to the PR's 17 files",
    "B: split the form.tsx change (and its two suites plus the hidden-field pin) into a follow-up card, and land this PR as 'Part of' with the widget half only"
    ],
    "recommendation": "A. Zone 1 binds 'never leave forms dropping the value', and the hidden-field ObjectForm pin is red without it. The change is one token plus a comment, the same defect class, and matches the clear-on-hide null in the same file. No open PR or live claim named form.tsx when I made the edits, and splitting would ship a known-red form path."
    }
    ],
    "out_of_scope_findings": [
    "class: a · InlineEditContextValue.draft JSDoc in packages/react/src/context/InlineEditContext.tsx (published @object-ui/react type) prescribes 'Read a field's live value as draft[name] ?? data[name]'. A host that follows it reads the SAVED value for an emptied field (a staged null or undefined). For an option widget that is the endless re-prune loop the objectui#7190 prune probe measured on the highlights strip at cfd1f8c, and the strip's own comment contradicts the JSDoc ('An OWN draft key wins even when its value is undefined'). DetailView and HeaderHighlight both use the spread, so there is no in-tree victim today; the trap is for a host author copying the doc · dedupe words: 'InlineEditContext draft ?? data live value', 'useInlineEdit emptied field saved value fallback', 'draft own key undefined null prune loop'"
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT — PR objectui#10464 at 11a8bbcd6 · entering the merge queue once CI is green

    domain:ui seat #1, session_01BA3nKVUwKQJf8DBxrSVtNC. The seat read the dev report and the diff against the triage grading 5819830160 and the claim 5825548591. No review-tier record is owed, because Clause-②: no holds: no export, type or schema moves. The widgets' onChange keeps its declared type. The form payload already carried null as "clear the stored value", because the clear-on-hide effect in the same form.tsx writes it, so its value vocabulary does not widen.

    Implemented-by:  claude/issue-10291-cleared-scalar-saves-null
    Reviewed-by:     session_01BA3nKVUwKQJf8DBxrSVtNC
    
    item reading
    shape draft · base main · Fixes #10291 · Clause-②: no at line start
    the defect, reproduced first At base 94436f42a, measured on the wire body: the inline-edit PATCH was {region:'apac', tags:[]}, and the ObjectForm EDIT PATCH and CREATE POST also lacked the cleared field. So the form path dropped the clear too, which answers the scope's "measure first" item: yes, forms dropped it as well
    the fix At the source, as the scope preferred. SelectField and RadioField emit onChange(null) for a cascade-cleared scalar. The form renderer's own independent clear (form.tsx) sets null too. That one runs where no widget is mounted (a field kept unmounted by visibleWhen, or the built-in select branch), and a widget-only intermediate measured red there. SelectField renders null as its placeholder, and undefined passes through untouched, so no host moves from uncontrolled to controlled. The multi-value clear is unchanged ([])
    pins + ablation New wire pins in plugin-detail (inline edit) and plugin-form (edit and create), plus a widget pin. Leg 1, widgets back to undefined: 7 red, and the host-only form case stays green. Leg 2, form.tsx back to undefined: 6 red, and the inline and mounted-form pins stay green. So each leg is pinned independently. Restores proven by blob
    re-pointed pins Six existing assertions move from undefined to null. They follow the new value and ⛔ are not weakened. The objectui#7190 prune probe's circuit breaker counts both null and undefined, so a regression back to undefined still trips it instead of hanging the runner
    tests + gates Root vitest run: 598 files, 7052 passed. turbo build closure green; type-check green for components, fields, plugin-form, plugin-detail and app-shell. eslint: 0 errors, and the +12 no-explicit-any warnings are all in the two new test files. The changeset family, doc and mock gates pass, and the governed guard reads NOT GOVERNED. check:doc-snippets is left to CI
    changeset minor on @object-ui/fields and @object-ui/components (a behaviour change): every sentence is checked true against the diff

    The dev's open question — answered by the seat (a file-surface question, no product ruling needed)

    The extended surface (form.tsx, its two host suites, two app-shell tests and one doc sentence): A, ratified. The scope binds "⛔ Never fix only inline edit and leave forms dropping the value", and without the form.tsx token the hidden-field ObjectForm pin is red. The seat re-read the open-PR file lists at review time: none of the 11 open PRs touches form.tsx, SelectField.tsx, RadioField.tsx, InlineEditSaveBar.tsx or select.mdx. The live claims of this seat's other flights (#7389, #9559 + #9306) do not name them either. The claim's file surface is amended in place (5825548591, section "Amended by the seat").

    Dev findings — each one routed

    Maintainer veto window (named in the round report; landing is not held): a single select or radio emptied by a cascade clear is now SAVED as null, which clears the stored value, on inline edit and in every form. Before, the stale value survived the save and came back on refresh.

    domain:ui seat #1 · review · 2026-09-25T03:40Z

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:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions