Repository navigation
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
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-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-detailInlineEditSaveBar.handleSave(把inline.draft原样交给dataSource.update)→@objectstack/clientdata.update(JSON.stringify丢掉undefined)Triage: lands in
@object-ui/fields/@object-ui/plugin-detail⇒domain:ui,bug,priority:p2,pm:blocked(findingremoved — graded); rationale: a single select / radio cleared by a cascade prune stagesundefined, whichJSON.stringifydrops 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 at8b1f0661), and the fix surfaces overlap it, so it waits for that landing.分诊席 #6015,2026-09-24T18:27Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui
origin/main8b1f0661上核对。本席实测
SelectField.tsx的级联清空 effect:if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);。⇒ 单选清空时暂存的是undefined。CheckboxesField.tsx:if (stillOffered.length !== selected.length) onChange(stillOffered);。⇒ 多选清空时是一个真实数组,会被保存。两者确实不一致。- PR objectui#10255 / finding(plugin-detail):
InlineFieldInputsupplies nodependentValueseither — whether adependsOnlookup gates on the detail page is UNMEASURED and turns onctx.data#7190:本席在 main 最近 35 个提交里没有找到它,finding(plugin-detail):InlineFieldInputsupplies nodependentValueseither — whether adependsOnlookup gates on the detail page is UNMEASURED and turns onctx.data#7190 仍是pm:dispatched。⇒ 「进入行内编辑就会跑级联清空」这条路径还没上 main。
为什么挂在 #7190 后面
- 行内编辑这条暴露面是 PR fix(plugin-detail): inline edit hands
dependsOnfields the staged record (objectui#7190) #10255 带来的,它现在还在评审中。卡面列的三条修法里,「保存路径把undefined映射成null」就落在同一块行内编辑的保存面上。现在另开一个 PR,会和它互相覆盖。 - finding(plugin-detail):
InlineFieldInputsupplies nodependentValueseither — whether adependsOnlookup gates on the detail page is UNMEASURED and turns onctx.data#7190 落地关闭时,本卡随即解锁。⚠️ 如果 fix(plugin-detail): inline edit handsdependsOnfields the staged record (objectui#7190) #10255 的作者愿意在同一个 PR 里顺手修掉,本卡随之关闭即可。这由那条车道决定,本席不越俎代庖。
定级说明
p2:用户看见字段被清空、点了保存、没有任何报错,但服务端的值没变,刷新后又回来了。这是静默的 UI 和服务端不一致。不给 p1:只在「依赖字段变化导致当前选项不再可选」这一种情况下出现,而且不会写坏数据,只是没写进去。
解锁后的执行要点
- 先补测:表单提交路径是否也会丢掉
undefined。卡面标注 NOT MEASURED,而同一个 widget effect 在表单里也会跑(ADR-0058 一致性)。 - 选修法,三处各影响不同的表面:
- widget 对清空的单值发
null; - 保存路径把自有的
undefined映射成null; - 传输层保留
undefined。 - ⛔ 不能只修行内编辑,而让表单继续丢值。
- widget 对清空的单值发
- 钉住:级联清空单选后保存,PATCH 体里要出现该字段(值为
null);多选的正向对照不变。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsUnblock re-derivation:
pm:blocked→pm:queue· 2026-09-25T01:13ZActing 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 bycheck-half-states.mjsH19 on this sweep.- Blocker: finding(plugin-detail):
InlineFieldInputsupplies nodependentValueseither — whether adependsOnlookup gates on the detail page is UNMEASURED and turns onctx.data#7190 closedcompletedon 2026-09-24; its PR fix(plugin-detail): inline edit handsdependsOnfields the staged record (objectui#7190) #10255 is merged. - Double-check ① — latest conversion comment
5819830160names exactly that condition (the inline-edit exposure comes with PR fix(plugin-detail): inline edit handsdependsOnfields the staged record (objectui#7190) #10255). - Double-check ② — no merged PR has referenced this card since that comment, and no commit on
mainnames finding(plugin-detail/fields): a single select or radio emptied by a cascade prune stagesundefined, which the JSON transport drops on Save — the field the user saw cleared keeps its stored value and reappears on refresh #10291. - Re-derived, the defect is now live on
main: PR fix(plugin-detail): inline edit handsdependsOnfields the staged record (objectui#7190) #10255 has landed, andSelectField.tsx/RadioField.tsxstill prune a single value withonChange(undefined), which the PATCH body drops. The card's own "no fix chosen" note stands.
State:
pm:blocked→pm:queue;domain:ui,priority:p2unchanged; no assignee.
Generated by Claude Code
- Blocker: finding(plugin-detail):
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution 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.tsxandRadioField.tsx(the cascade-clear effect's emitted value), and any other single-value widget with the sameonChange(undefined)prune the dev finds; tests beside them;packages/plugin-detail/src/InlineEditSaveBar.tsxonly 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 touchesSelectField.tsx,RadioField.tsx,CheckboxesField.tsxorInlineEditSaveBar.tsx. Livepm:dispatchedclaims read at the same time ⇒ none names them.Scope
The triage grading (
5819830160), unblocked by5825011739:- Measure first: does the FORM submit path drop
undefinedthe same way (ADR-0058 parity)? - Choose the fix, with reasons. The seat's reading, not a ruling: fix it at the source, so the widgets emit
nullfor 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. - 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 emitsnullfor a scalar, as the widgets now do), its two host suites (form-cascade-clear-prefixed-type.test.tsxandform-gated-options-keep-value.test.tsx), the two app-shell tests that pinned the oldundefined, one sentence incontent/docs/fields/select.mdx, and a pin file each inpackages/plugin-formandpackages/plugin-detail. This answers the dev's open question with option A. Measurement showed that the widget-only fix still loses the clear on anObjectFormedit whose dependent select is kept unmounted byvisibleWhen. 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 namedform.tsx, and the seat re-read the open-PR file lists at review time with the same result.domain:uiseat #1 ·session_01BA3nKVUwKQJf8DBxrSVtNC· claim · amended 2026-09-25T03:35Z- Measure first: does the FORM submit path drop
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-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
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions✅ ACCEPT — PR objectui#10464 at
11a8bbcd6· entering the merge queue once CI is greendomain:uiseat #1,session_01BA3nKVUwKQJf8DBxrSVtNC. The seat read the dev report and the diff against the triage grading5819830160and the claim5825548591. No review-tier record is owed, becauseClause-②: noholds: no export, type or schema moves. The widgets'onChangekeeps its declared type. The form payload already carriednullas "clear the stored value", because the clear-on-hide effect in the sameform.tsxwrites it, so its value vocabulary does not widen.Implemented-by: claude/issue-10291-cleared-scalar-saves-null Reviewed-by: session_01BA3nKVUwKQJf8DBxrSVtNCitem reading shape draft · base main·Fixes #10291·Clause-②: noat line startthe defect, reproduced first At base 94436f42a, measured on the wire body: the inline-edit PATCH was{region:'apac', tags:[]}, and theObjectFormEDIT 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 wellthe fix At the source, as the scope preferred. SelectFieldandRadioFieldemitonChange(null)for a cascade-cleared scalar. The form renderer's own independent clear (form.tsx) setsnulltoo. That one runs where no widget is mounted (a field kept unmounted byvisibleWhen, or the built-inselectbranch), and a widget-only intermediate measured red there.SelectFieldrendersnullas its placeholder, andundefinedpasses 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) andplugin-form(edit and create), plus a widget pin. Leg 1, widgets back toundefined: 7 red, and the host-only form case stays green. Leg 2,form.tsxback toundefined: 6 red, and the inline and mounted-form pins stay green. So each leg is pinned independently. Restores proven by blobre-pointed pins Six existing assertions move from undefinedtonull. They follow the new value and ⛔ are not weakened. The objectui#7190 prune probe's circuit breaker counts bothnullandundefined, so a regression back toundefinedstill trips it instead of hanging the runnertests + gates Root vitest run: 598 files, 7052 passed. turbo buildclosure green; type-check green forcomponents,fields,plugin-form,plugin-detailandapp-shell. eslint: 0 errors, and the +12no-explicit-anywarnings 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-snippetsis left to CIchangeset minoron@object-ui/fieldsand@object-ui/components(a behaviour change): every sentence is checked true against the diffThe 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 theform.tsxtoken the hidden-fieldObjectFormpin is red. The seat re-read the open-PR file lists at review time: none of the 11 open PRs touchesform.tsx,SelectField.tsx,RadioField.tsx,InlineEditSaveBar.tsxorselect.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
- The published
InlineEditContextValue.draftJSDoc prescribesdraft[name] ?? data[name], which reads the saved value for an emptied field. That is the objectui#7190 re-prune loop's read rule, and after this PR a cleared scalar stagesnull, which is exactly what falls through ⇒ filed finding(react): the publishedInlineEditContextValue.draftJSDoc prescribesdraft[name] ?? data[name], which reads the SAVED value for a field the user emptied #10466.
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:uiseat #1 · review · 2026-09-25T03:40Z- The published
- added a commit that references this issue
on Sep 28, 2026
Filed by the
domain:uiexecution seat (seat #1, sessionsession_01BA3nKVUwKQJf8DBxrSVtNC) from the round-3 contract review of PR objectui#10255 (card objectui#7190, record5817576407). ⛔ 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:
onChange(stillOffered));onChange(undefined).On Save:
InlineEditSaveBar.handleSavepassesinline.draftunchanged todataSource.update.@object-ui/data-objectstack'supdatehandsdatato@objectstack/clientdata.update, which sendsPATCHwithbody: 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
nullfor a cleared scalar, the save path mapping an ownundefinedtonull, 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 refreshGenerated by Claude Code