Skip to content

Commit 0896838

Browse files
fix(plugin-form): the line-items panel neither draws nor saves the lines it holds for another parent record (objectui#10740) (#10751)
Fixes #10740 Clause-②: no A component's internal state and its Save guard; no declared surface moves. `@object-ui/plugin-form` publishes `.` only, and no export, type, prop or registration changes. Dispatched by the `domain:ui` seat 2, session `https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN` (`mode:subagent`), executing triage `5852239622`. Round 3 closes one residual write path (R7) the contract review found; the Round 3 section at the foot carries its head and evidence. ## What changed - **`packages/plugin-form/src/LineItemsPanel.tsx`** (+67 / −4 against base). The panel now records which parent its held `rows` / `original` belong to: one `useState`, `rowsHeldFor`, written wherever the rows are written — beside a load's commit (`setRowsHeldFor(parentId)` next to `setRows` / `setOriginal` / `setDirty(false)`), inside the CURRENT load's `catch` that fails while nothing is held (an updater that keeps a value already held and otherwise adopts `parentId`, inside `isCurrent()`), and in `onChange` for an edit made before any load has settled (the same keep-or-adopt updater, `parentId` narrowed to a string first). One predicate, `heldForAnotherParent = rowsHeldFor !== undefined && rowsHeldFor !== parentId`, is read at three sites: the Save button's `disabled`, the first statement of `save` after its existing `dataSource` / `parentId` / `childObject` guard (it returns before `setSaving`, before any batch is built), and the render, where a placeholder (`data-testid="line-items-held-for-another-parent"`) stands in for the grid ahead of `LineItemsField`. `save`'s and `onChange`'s dependency lists gain what they read (`heldForAnotherParent`, `parentId`). - Unchanged: the rows commit's three existing statements (`setRows` / `setOriginal` / `setDirty(false)`) and the `loading` release (objectui#10712's PR 2 surface), the `loadSeqRef` / `isCurrent()` error rule from PR objectui#10738, `save`'s two writes to `error`, every other dependency list, every hook call, prop, export and registration. The commit block gained the `setRowsHeldFor(parentId)` line and the `catch` was re-braced to add the adoption; the three commit statements themselves did not move. - **`packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx`** (new, +389 / −0): nine rows through the real `SchemaRenderer` and `record:line_items`, every `find` and `batchTransaction` a deferred promise settled by hand. - **`.changeset/10740-line-items-held-for-another-parent.md`** (new, +24 / −0, `'@object-ui/plugin-form': patch`). ## H1 — the reproduction on base `5ad3b88861`, and its controls A throwaway probe (deleted, never committed) through the real `SchemaRenderer` and `record:line_items`, ids explicit. The column is `label` (aria-label `Line`), the child object `po_line`, the relationship field `po`. Readings at base, then at the current head `854e0922cc` (whose only change since `ef16b93486` is prose: the readings are the pin's, re-run green there): | sequence | base `5ad3b88861` | head `854e0922cc` | |:--|:--|:--| | `p1` answers `l1`, edited to `p1 line edited`; the host re-renders with `parentId: 'p2'`; `p2`'s load rejects `p2 load failed`; then Save | banner `p2 load failed`; drawn lines `['p1 line edited']`, inputs enabled; **Save enabled**; Save ⇒ `batchTransaction` ops `[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p2'}}]` (`find` 2 calls, `batchTransaction` 1) | banner `p2 load failed`; drawn lines `[]`, no grid, the placeholder drawn; **Save disabled**; no batch (`find` 2, `batchTransaction` 0) | | **R7** (the round-3 route): mount `p1` with NO adapter (`load` returns at `!dataSource`, the grid is drawn, `rowsHeldFor` stays undefined); type `typed under p1`; the host moves to `p2`, still no adapter; the adapter arrives and `p2`'s load fails; then Save | grid drawn with `['typed under p1']`, Save enabled; Save ⇒ `[{object: 'po_line', action: 'create', data: {label: 'typed under p1', po: 'p2'}}]` — a p1-typed line created under p2 | placeholder drawn, `[]`, **Save disabled**, no batch | | lit control: the first row, but `p2`'s load answers `l2` `p2 line` | drawn `['p2 line']`, no banner, Save disabled (nothing dirty); edit `l2` + Save ⇒ `[{object: 'po_line', action: 'update', id: 'l2', data: {label: 'p2 line edited', po: 'p2'}}]` | identical | | same-parent failed RE-load: `p1`'s edit held, a host re-render with `limit: 200` re-runs `p1`'s load, which rejects `p1 reload failed` | banner; drawn `['p1 line edited']`, inputs enabled; Save enabled; Save ⇒ `[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p1'}}]` | identical (the edits are kept under their own parent) | | the DECLINE path: `p1`'s edit held, a re-render with `parentId: 'p2'` and a refused filter (`{note: {$regex: 'a.c'}}`); the load declines before any fetch | malformed-filter notice drawn, no grid; **Save enabled** over the held (off-screen) rows; Save ⇒ `[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p2'}}]` (`find` 1 call: the load never ran) | Save disabled; no batch | The first row is the card's reading, op for op. R7 and the DECLINE row are two routes the card did not name: R7 is a line typed under a grid drawn with no adapter, adopted by the first parent whose load later fails; the DECLINE row reaches the guard through the load declining before its `catch`. Both are closed by binding the owner to the rows wherever the rows are written. ## H2 — the shape: refuse by `rowsHeldFor`; nothing is cleared The triage offered two shapes, "clear `rows` / `original`" or "refuse Save". This PR refuses, on a value written wherever the rows are written, and clears nothing. Why, measured: 1. **The decline path.** The DECLINE row above: a swap to `p2` with a refused filter (and, by the same code path, a lost `dataSource`) declines the load before its `try`, so a clear in the `catch` leaves `p1`'s held rows saveable under `p2`. The guard reads the rows' own parent and closes this path too (pinned; red at base). 2. **One writer of the owner per writer of the rows.** `rowsHeldFor` moves where `rows` move (the load's commit, `onChange`) and once more where a CURRENT load's failure leaves the empty rows on screen, so the two cannot disagree. This also covers the one-render window between the host re-render that moves `parentId` and the effect that starts the new load: `rows` are still `p1`'s, `rowsHeldFor` still says `p1`, Save is off in that frame too. R7 is the case where this failed at the round-2 head: `onChange` moved the rows without moving `rowsHeldFor` while it was unset, so a line typed under an adapter-less mount stayed ownerless until the next failure adopted it. `onChange` now writes the owner too. 3. **The same-parent rule.** A transient failure on a refresh must not discard the author's unsaved edits (the RE-load row, and the "RE-load of the SAME parent" pin). Held rows are never cleared, so a swap `p1` → `p2` (failed) → `p1` (failed) draws `p1`'s held edit again under `p1` (pinned as a control, "a swap BACK to p1"). A clear on the `p2` failure would have discarded that edit. 4. **Adoption before a settle.** With nothing held yet, the parent on screen is adopted the moment the rows first take a value: an edit made in an unsettled grid (`onChange`, R7's fix), or the empty rows a CURRENT load's failure leaves on screen (the `catch`, inside `isCurrent()`). Either way a line the panel then holds belongs to the parent it was entered under, not to a parent the panel later moves to. Measured at base: a first load for `p1` fails, a line is added, the panel moves to `p2` whose load fails ⇒ the added line is drawn under `p2` with Save enabled (the defect, as a `create`). Pinned; red at base. A superseded failure adopts nothing (the `catch` is inside `isCurrent()`). 5. **What the refused state draws.** After a failed load, the failure's banner (the adapter's message, as before) and a muted placeholder where the grid would be; after a refused-filter decline, that filter's own notice, as before (only Save moves on that route); after a lost-adapter decline, the placeholder alone. No line, editable or otherwise. A first load that fails with nothing held is unchanged: banner plus the empty grid with its entry row (pinned as a control). A load for the current parent that commits takes the grid back (pinned). The function-level guard in `save` is the write contract's own check, on the function every caller reaches, in the same spirit as the `childObject` guard beside it. It is redundant on the UI path by design: the button and the function read the same render's predicate, so no click through the rendered UI reaches `save` while the button is off. See A2 below. ## H3 — every path from the held rows to a write 1. **`save`** → `buildMasterDetailEditBatch(parentObject ?? '', parentId, parentPatch, [{rows, original, childSchema, …}])` → `runBatchTransaction`. Guarded at both readers: the button's `disabled` and the function's first statement. The rollup `sumRows(rows, …)` is computed inside the same function, behind the same guard. 2. **`onChange`** → `setRows(next)`, `setDirty(true)`, and the owner adoption. While the grid is drawn, `rowsHeldFor` is the current parent — set by the load that offered the grid, or, when the grid was offered before any load settled, by this edit itself — so an edit belongs to the parent on screen, never to a parent the panel later moves to. In the refused state the grid is not drawn (ablation A3 re-opens exactly this and the "no p1 line is drawn" rows go red). 3. **The load's commit** → `setRows` / `setOriginal` / `setDirty(false)` / `setRowsHeldFor(parentId)`: the writer that defines whose the rows are on a successful load. 4. **`LineItemsField` (`GridField` in `@object-ui/fields`)**: `grep -c useEffect` on `GridField.tsx` at this base = 0; every `emit` / `onChange` call sits in a user handler (`setCellValue`, `addRow`, the remove handler, the reorder handler), all routed through the panel's `onChange` in (2). Nothing changes rows without user input, the reading PR objectui#10650 made and this PR re-took. 5. **No values bubble up.** The panel takes `schema` only; the `record:line_items` registration (`LineItemsPanelRenderer` inside `ElementDataSourceGate`) passes nothing else, and the post-save `load()` reads no rows. No `forwardRef` / `useImperativeHandle` / key handler; `save` is referenced once, at `onClick={save}`. So there is one write path (`save`), guarded twice, and one edit path (`onChange`), which brands every row it writes with the parent on screen. **R7 is closed:** a row can no longer reach a write bearing a parent it was not entered under, whether it was typed before a load settled or held from a previous parent — in the refused state the grid is not drawn, and in every drawn state the owner is unset or equals `parentId`, and the first edit sets it to `parentId`. ## Pins and ablations `LineItemsPanel.heldForAnotherParent-10740.test.tsx`, nine rows. Base and ablation legs ran on COMMITTED source (`ef16b93486`, panel blob `5884928c7a`) under `os-verify-lock`; the one later commit, `854e0922cc`, moves prose only (no code token, proof in the Round 3 section), and the pin is re-run green there. Each mutation went through objectstack's `scripts/ablation-replace.mjs` in WRAP mode (anchor declared to hit once, counts and blob hashes read back from disk, restore spelled `git checkout HEAD --` on the absolute path and proven by blob == HEAD and an empty `git diff HEAD`). Two whole-file base legs, each swapped in by hash under a shell `trap` and restored the same way: the card base blob `0c498ce007` (`held ?? parentId` occurrences 0), and the round-2 head blob `47696a5819` (occurrences 1 — the fix before R7 was closed). No build is on the resolution path: the pin imports `./index` relatively and every `@object-ui/*` specifier resolves to `src` through the root vitest alias, so `ablation-dist-preflight` does not apply. | row | base `0c498ce007` | round-2 head `47696a5819` | head `854e0922cc` | red under | |:--|:--|:--|:--|:--| | p1 edited, swap to p2 whose load FAILS: no p1 line drawn, Save off, no batch | **red** | green | green | A1, A3 | | the same through a load that DECLINES (refused filter): Save off, nothing sent | **red** | green | green | A1 | | a line added under a FIRST load that failed, then a swap to p2 whose load fails: not drawn under p2, Save off | **red** | green | green | A1, A3 | | **R7**: a line typed under an ADAPTER-LESS mount on p1, a swap to p2 with no adapter, then the adapter arrives and p2's load fails: no line drawn under p2, Save off, no batch | **red** | **red** | green | A1, A3, A6 | | control: a swap to p2 whose load SUCCEEDS replaces p1's lines; a save after editing p2's line carries `l2` under `p2` and nothing of `l1` | green | green | green | A4 | | control: a RE-load of the SAME parent that fails keeps the held edit drawn, editable, Save on; the save carries `l1` under `p1` | green | green | green | — | | after a refused state, a load for the CURRENT parent that commits takes the grid back | **red** | green | green | A3, A4 | | control: a swap BACK to p1 whose load fails again draws p1's held edit again | green | green | green | — | | control: a FIRST load that fails draws the banner and the empty grid, as before | green | green | green | — | - **Base leg, card base blob `0c498ce007`**: `Failed Tests 5` (the four refusal rows plus the recovery row), 4 green. Restore proven (blob == HEAD `5884928c7a`, `git diff HEAD` empty). - **Base leg, round-2 head blob `47696a5819`**: `Failed Tests 1` — exactly the R7 row. This is the one the round-3 close fixes; every other row was already green at the round-2 head. Restore proven. - **A1**, the button's `|| heldForAnotherParent` removed: 4 failed (the three original "Save off" rows and R7). - **A2**, the `save` guard neutralised (`if (false && heldForAnotherParent) return;`): **0 failed, 9 passed.** As under H2: with the button guard in place no UI click reaches `save`, so this hunk is observable only together with A1. Kept as the write contract's own guard, not claimed as pinned. - **A3**, the render branch made unreachable (`false && heldForAnotherParent ?`): 4 failed (the two "not drawn" rows, R7 and the recovery row). - **A4**, `setRowsHeldFor(parentId)` deleted from the load commit: 2 failed — the "swap to p2 SUCCEEDS" control and the recovery row, both at `expected [] to deeply equal ['p2 line']`: with the commit no longer branding the rows, `rowsHeldFor` stays at the value the p1 edit adopted, so p2's own freshly loaded lines are refused behind the placeholder. - **A5**, the `catch` failure-adoption deleted: **0 failed, 9 passed.** Since Round 3, `onChange` also adopts, so every row whose owner the `catch` used to set now has it set by the typed edit (or is an empty grid, never saveable). The `catch` adoption is retained as the documented adopt-on-failure behavior; like A2 it is kept and not independently pinned. (At the round-2 head, before `onChange` adopted, A5 reddened the "line added under a FIRST load that failed" row.) Its one remaining observable effect is the B5 Acceptance note below. - **A6**, the `onChange` owner adoption deleted (`setRowsHeldFor((held) => held ?? owner);`): **1 failed — exactly the R7 row.** This is the ablation of the round-3 close. - Each ablation leg printed `ok mutation landed: anchor 1 to 0` with the blob change, and `ok restored: blob == HEAD (5884928…) and git diff HEAD is empty`; the tree was clean before and after (`git status --short` empty). ## Gates at head `854e0922cc` Locked runs (`os-verify-lock`, `VERDICT command-exit 0` on each): - `turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2`: `Tasks: 11 successful, 11 total`. - `pnpm --filter @object-ui/plugin-form run type-check`: exit 0 with `tsc --noEmit && tsc -p tsconfig.test.json` echoed; `--listFilesOnly` counts the pin once in the `tsconfig.test.json` program and `LineItemsPanel.tsx` once in the build program. - `pnpm --filter @object-ui/plugin-form build`: exit 0, `[unplugin:dts] Declaration files built`, `dist/index.js` emitted — the dts plugin fails on any type error and did not. The three built artifacts are byte-identical before and after the wording commit: `dist/index.js` sha256 `9e08b5589db5…`, `dist/index.d.ts` `08fe75fa41b4…`, `dist/index.umd.cjs` `19fddff1b34f…`, built at `ef16b93486` and again at `854e0922cc`. - the pin: `Tests 9 passed (9)`. The seven existing `LineItemsPanel*` pins plus `recordFormsErrorClears-10682.test.tsx` at `ef16b93486`: `Test Files 8 passed (8)`, `Tests 102 passed (102)` (the later commit moves no code token). - the whole package, `pnpm exec vitest run --maxWorkers=2 packages/plugin-form/`, at `7352890ae4` (round 1): `Test Files 131 passed (131)`, `Tests 1507 passed | 1 skipped (1508)`. The later commits touch only `LineItemsPanel.tsx`, its pin and the changeset, covered by the pin and sibling runs above. Light gates, each exit 0 at `854e0922cc`: `check:new-line-citations` (`VERDICT new-cross-file-line-citations: 0 new citation(s)`), `check:control-bytes`; and at `ef16b93486` (no source token moved since): `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:shell-escape-residue`, `check:i18n-keys`, `check:handler-key-reads`, `check:element-data-source-declaration`, `check:self-import`, `check:unreferenced-sources`, `type-check:coverage`, `lint:coverage`, `check:phantom-deps`, `check:published-tsconfig-exclude`, `check:metadata-write-doors`. Changeset gates at `854e0922cc`: `check-changeset-presence` (`2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`), `check-changeset-claims` (`No pending changeset names a file this change touches`), `check-changeset-overwrite` (`No pre-existing changeset was modified or deleted`), `check-pending-changeset-literals`; `check-changeset-no-major` and `check-changeset-fixed` at `ef16b93486`. `check-governed-merges.mjs --test` over the three paths: NOT governed (0 of 3 paths hit the register). **eslint, narrowed to the 2 changed ts/tsx files** (`eslint --no-inline-config --format json`): exit 0, the JSON counts 2 files, 0 errors. Narrowing evidence: the population is `eslint.config.js`'s `files: ['**/*.{ts,tsx}']`; `--print-config` on `LineItemsPanel.tsx` shows `parserOptions: {}` and no `project` / `projectService`, so no rule is type-aware and this diff cannot move the verdict of a file it does not touch. `LineItemsPanel.tsx` carries 18 warnings, the same count and rule multiset as its base blob (14 `no-explicit-any`, 2 `set-state-in-effect`, 2 `exhaustive-deps`); the pin carries 12 `no-explicit-any`. **NOT MEASURED locally, declared to CI**: the repo-wide `pnpm lint` and the rest of the farm. ## Serial At claim time no open PR touched `LineItemsPanel.tsx` (claim `5852470959`). Before the round-1 pushes `origin/main` was `01700dcd7d` (four commits past the base); before the round-4 push it is `704e05b09d`, nine commits past the base. `git merge-tree --write-tree` against it exits 0 with a clean tree (`be8cdeefbd`); at the round-5 review `origin/main` is `f308a655b8` (#10708, eleven commits past the base), against which the tree is `01802fbba7`, also clean. None of the eleven touches a file this branch touches (#10708 touched `skills/objectui/` only); #10752 (`704e05b09d`) touched `packages/plugin-form` elsewhere (six form arms, `recordInvalidation.ts`, `formArmsInvalidation-10715.test.tsx`, and the header comment of the sibling pin `recordFormsErrorClears-10682.test.tsx`), and #10749 moved `pnpm-lock.yaml`. objectui#10712's PR 2 is serial behind this PR, as the claim says. ## H5 — pending changesets `git grep` over `.changeset/` for `LineItemsPanel`, `line-items`, `line items`: 14 pending files. The sentences that describe this panel's load, rows or save were re-read against this change: `10682-form-error-clears-2.md` (only the current load writes the banner; a superseded load neither raises nor clears it — still true), `10631-master-detail-save-in-flight.md` (the panel's grid is disabled while its own save is in flight — still true), `6194-line-items-row-fetch-decline.md` (the `childObject` declines and `save`'s guard — still true), `6188`, `9050`, `9925`, `10163`, `8221`, `9333` (each about another aspect of the panel — untouched). None describes what a failed or declined load for another parent leaves on screen, so no sentence is falsified and no dated note is needed; the new changeset declares this change. ## objectui#10712, measured, not widened The same probe took the two sequences objectui#10712's release comment carries for its PR 2, at base and at this head: - a late `p1` answer landing after `p2` committed: base draws `['p1 line']` under `p2`; head draws the placeholder (`rowsHeldFor` moved to `p1` with the superseded rows, so they are refused), Save off. The current answer, `p2`'s lines, is not kept: the run guard on the rows commit is still that PR's to add. - a save started on `p1` that lands after the swap to `p2`: its captured `load` issues `find` with `po: 'p1'`; base draws `['p1 line edited']` under `p2`; head draws the placeholder. The same partial mitigation, the same remaining item. objectui#10712 remains open; nothing here is scoped to it. ## Round 3 The round-2 head `11bf099f11` FAILED the contract review (`5852845813`) on R7 — a line typed into a grid drawn with no adapter, adopted by the first parent whose load later failed, then saved under it — and on four wording items. Every other round-2 claim was reproduced by the review. - **Round-3 range `11bf099f11..ef16b93`**, name-status / numstat: `M packages/plugin-form/src/LineItemsPanel.tsx` (+34 / −14), `M packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx` (+42 / −0), `M .changeset/10740-line-items-held-for-another-parent.md` (+11 / −10). Two commits: `23f6bbc0` closed R7 in `onChange` and fixed the wording; `ef16b93486` gave the `onChange` owner updater a type-safe spelling (see Deviations). - **The close.** `onChange` now writes the rows' owner beside the rows when nothing is held yet — the seat's ruling permits the `onChange` form as "strictly safer" because it covers any way a grid can be offered before a load settles (the adapter-less decline, and a superseded run releasing `loading`, objectui#10712's surface), not only the two the ruling enumerated. `parentId` is narrowed in an `if (parentId)` block before the updater closes over it. - **The pin.** The new R7 row is red on the card base blob AND on the round-2 head blob `47696a5819`, green at head, and reddened by A6 (the `onChange` owner adoption deleted). Table above. - **Round-3 wording follow-up, `854e0922cc`** (prose and comment only; the round-3 contract review `5853196779` passed the code and failed the published text). One commit, `M .changeset/10740-line-items-held-for-another-parent.md` (+3 / −2), `M packages/plugin-form/src/LineItemsPanel.tsx` (+4 / −4). The changeset parenthetical now reads "(after a failed load a placeholder stands where the grid would be, under the failure's banner; after a refused-filter decline that filter's own notice stands there, as before; after a lost-adapter decline the placeholder stands alone)"; its frontmatter is byte-identical (md5 of the three frontmatter lines `21fc281303c02040481bc4b368c13334` before and after). The `onChange` comment's "The only grid offered before a load has settled…" became "The grid this was found on is the one drawn when the load declined for want of an adapter", which stays true against the superseded-release window (the empty grid drawn while a later read is pending). No code token moved: the commit's `.tsx` hunk has zero non-comment lines (`git diff ef16b93 854e092 -- packages/plugin-form/src/LineItemsPanel.tsx`, filtered to changed lines that do not start with `//`, is empty); type-check and the dts build exit 0; and the three `dist/` artifacts hash-equal before and after (Gates above). - **Gates.** The plugin-form dependency-closure build, `type-check` (both `tsc` programs) and `build` (the dts plugin) all exit 0 at `854e0922cc`; the pin (9) is green there; the seven sibling pins plus `recordFormsErrorClears-10682` (8 files / 102) and the light gates are green at `ef16b93486`; the changeset gates, `check:control-bytes` and `check:new-line-citations` are green at `854e0922cc`, listed above. ## Acceptance notes - **The function-level guard is unpinned.** A2 moves no row, for the structural reason under H2 and in the pin's header. Kept as the write contract's own check; dropping it is a one-line change if the seat prefers pins-only code. - **The `catch` failure-adoption is unpinned since Round 3.** A5 now moves no row: the `onChange` adoption covers the same typed-line rows, and an empty grid left by a failed load is never saveable. Kept as the documented adopt-on-failure behavior, parallel to A2. - **B5.** After a FIRST load that fails with nothing typed, a swap to another parent whose load also fails draws the placeholder, not the empty grid (base drew the empty editable grid); the catch adoption's one observable effect now that `onChange` adopts. Unpinned. - **The refused state's placeholder** is hard-coded English, like this panel's other placeholders (`Loading…`, `Save the record first to add line items.`, the `childObject` hint); only the malformed-filter state goes through `createSafeTranslation`. Observation, not filed. - **A superseded save's failure** is still written unguarded (PR objectui#10738's note); not re-measured here. - **A first load that fails** still draws an empty editable grid under the banner, as before this card; a line typed there now belongs to that parent and stays with it (H2 item 4). - No class (a), (b) or (c) finding beyond the objectui#10712 rows its release comment already carries. ## Deviations - **Attribution.** Commit trailers are the dispatch's model-free pair, and this body ends with the role file's session-URL footer. The harness attribution reminder asked for other lines. - **The seat's prescribed close needed a type-safe spelling.** The ruling's line `setRowsHeldFor((held) => held ?? parentId)` (and equivalently the `onChange` form) does not type-check: in `onChange`, `parentId` is `string | null | undefined` (`RecordContextValue.recordId` is `string | null | undefined`) with no `load` guard above it to narrow it, and the setter's state is `string | undefined`, so the updater's `held ?? parentId` widens to include `null` — CI job 108556174339 red at `src/LineItemsPanel.tsx` `error TS2345` on commit `23f6bbc0`, breaking the plugin-form build and the six gates behind it. Fixed in `ef16b93486` by capturing the narrowed value: `if (parentId) { const owner = parentId; setRowsHeldFor((held) => held ?? owner); }`. Semantics unchanged (a grid is only offered with a parent bound). The `catch`'s copy of the line type-checks because `load`'s early `if (!dataSource || !parentId) return;` narrows `parentId` to a string before it. - **The ablation legs run from inside the worktree**, because `ablation-replace` resolves the repository from its cwd; run through the lock wrapper's default cwd it exits 2 at its own prerequisite (nothing written, nothing measured). - **A removed pin row (Round 1).** The pin's first commit carried a "defence in depth" row that cleared the Save button's `disabled` on the DOM node and clicked. React filters a click by the fiber's `disabled` prop, not the DOM property, so the click never reached `save` and the row passed vacuously (A2 showed it). It was removed in `11bf099f11` rather than kept as a false pin; the pin header states what it would have pinned and why nothing can. - **Round-4 body corrections (published text only).** The round-3 review found four false evidence statements in the returned body and two overstatements; all are replaced here with the record's text: the pin table's "red under" column (A4 reddens only the p2-SUCCEEDS control and the recovery row), the A4 bullet's cause, the Serial paragraph (main re-read before the push, the merge-tree re-run at the head), the round numbering (`11bf099f11` was round 2; `23f6bbc0` and `ef16b93486` are round 3), H3's closing and H2 item 2. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f308a65 commit 0896838

3 files changed

Lines changed: 480 additions & 4 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
---
4+
5+
fix(plugin-form): the line-items panel neither draws nor saves the lines it holds for another parent record (objectui#10740)
6+
7+
The `record:line_items` panel (`LineItemsPanel`) holds one set of rows, replaced
8+
when a load commits. When the host moved the panel to another parent record
9+
without a remount and that parent's load failed, the previous parent's lines
10+
stayed on screen, edits included, drawn editable with Save enabled; Save then
11+
wrote them under the new parent's id, moving another record's lines to it. A load
12+
that declined for the new parent (a refused filter) drew the decline's own notice
13+
but left the same Save enabled over the same held rows.
14+
15+
Now the panel records which parent its held rows belong to, wherever the rows are
16+
written: a load's commit, or an edit made before any load settled. While that
17+
parent is not the current one, no held line is drawn (after a failed load a
18+
placeholder stands where the grid would be, under the failure's banner; after a
19+
refused-filter decline that filter's own notice stands there, as before; after a
20+
lost-adapter decline the placeholder stands alone), the Save button is off, and a
21+
save sends nothing. A load for the current parent that commits
22+
takes the grid back. Unchanged: a load for the new parent that succeeds replaces
23+
the rows as before, and a re-load of the same parent that fails keeps the
24+
author's unsaved edits drawn, editable and saveable under that parent.

0 commit comments

Comments
 (0)