Repository navigation
Commit 40fa433
fix(app-shell): publish review sheet lists no Options change on a picklist-bound field nobody edited (#29949)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4xjiquevJqfBXiUoqfQwa1)_
Fixes #29899
## 维护者速读
- **改了什么:** Studio 发布审阅面板在比较对象的已发布版和草稿时,把绑定了 picklist 的字段都按"存储形"(不带
`options`)来比,不再把服务端解析出来的选项当成作者改动。
- **为什么改:** 只要对象里有绑定 picklist 的字段,每次发布面板都会多出一行"~ Tier ·
Options",作者根本没改过,面板失去可信度,真改了也分辨不出来。
- **风险与回滚:** 只动 `@object-ui/app-shell` 的面板 diff,不改任何写入、协议或存储;只影响声明了
`picklist` 的字段的 `options` 比较。回滚即 revert 本 PR。
- **先看:** `ui/packages/app-shell/src/preview/DraftChangesPanel.tsx` 的
`computeChangeDetail` 开头的 `stored(...)` 两行;新测试
`DraftChangesPanel.picklistServedOptions-29899.test.tsx`。
- **两分钟验证:** `pnpm --dir ui exec vitest run
packages/app-shell/src/preview/__tests__/DraftChangesPanel.picklistServedOptions-29899.test.tsx`,预期
3 passed。
- **需要你做的:** 无,席位落地。
Clause-②: no
Metadata-protocol: none
## What changes
`computeChangeDetail` (the diff behind Studio's publish review sheet)
now reads both versions of an `object` in the form the publish door
**stores** a picklist-bound field: without `options`. It does so by
passing each envelope-free body through `dropServedPicklistOptions` from
`@object-ui/data-objectstack`, the function the Studio and Setup writers
already use to turn a served object read back into the authored form
before a PUT (objectui#10202, objectui#11692). The step runs only when
the entry's type is `object`.
The panel already imports from `@object-ui/data-objectstack`
(`readDraftChanges`), so the console's eager graph gains no new module.
### Why "compare like with like (both stored)" and not "skip `options`
in the diff"
The card offered both. Normalizing to the stored form keeps every real
change visible; skipping a key would hide some.
- `dropServedPicklistOptions` touches a field only when **that
definition** names a picklist (`picklist !== undefined`, the
`FieldSchema` refinement's own test). A field with inline `options` is
returned by reference, so a real options change there is still listed.
The CONTROL test proves it.
- A field moving between a picklist and inline options still lists
**both** `picklist` and `options`, in either direction, because the side
with inline options keeps them. A diff-level "ignore `options` when the
field declares `picklist`" rule would hide the `options` half of that
move. It would also have to choose which side's `picklist` counts.
- The authoring door refuses `picklist` and `options` together, so the
stored form of a bound field never carries `options`. Normalizing to it
loses nothing an author could have written.
### Mechanism, measured against `origin/main` at `21510bba`
The card's PM assumptions hold:
- **The live read carries the resolved options; the draft read does
not.** `readMetaItem`
(`packages/metadata-protocol/src/protocol-meta-item-reads.ts`) returns
early for `state: 'draft'` ("Draft reads stop here"). It serves the
converted draft row through `governServedObject` and never reaches the
object fold. The active read folds the object through
`foldObjectExtendersFromRegistry` →
`SchemaRegistry.foldObjectExtendersOnto` → `finishServedObject` →
`resolvePicklistFieldsOnto`
(`packages/objectql/src/picklist-resolution.ts`). That last step writes
the resolved `options` onto each bound field and keeps `picklist`.
- **No path found where the draft itself carries resolved options.**
Studio's writers drop them before a save (`dropServedPicklistOptions`),
and the authoring door refuses a body that carries both keys. A body
that did carry both would also be normalized here on both sides.
- **The fix is not server-side.** Serving the resolved options on a
bound field is the declared contract (`PicklistServedFieldSchema`,
`@objectstack/spec` `data/picklist.zod.ts`): every object read serves
it, and the write door judges values by the same served field. The draft
read serving the stored row is ADR-0005's draft lifecycle. Neither read
is wrong. The diff was comparing two forms, so the ui lane owns the fix.
## Tests
New file
`ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.picklistServedOptions-29899.test.tsx`.
It renders the real `DraftChangesPanel` against routes where the
published read serves `tier` with resolved options and the draft read
serves it stored:
1. A picklist-bound field `tier`, and a draft that changes only
`notes.label`: the drill-in renders exactly one row, `~ Again notes
notes · Label`, and no `Options`.
2. **CONTROL:** an inline-options select `status` whose options really
changed, beside the same bound `tier`: exactly one row, `~ Status status
· Options`.
3. `computeChangeDetail` direct: a bound field reads unchanged; bound →
inline and inline → bound each list both `picklist` and `options`; the
array `fields` shape reads the same.
<details><summary>Ablation (fix removed → new tests red →
restored)</summary>
Fix committed first (`f4d64a21`). Then `node
scripts/ablation-replace.mjs` swapped the anchor `type === 'object' ?
dropServedPicklistOptions(body) : body;` for `body; // ABLATION-29899`
and ran the new file under the verify lock. The test imports the panel
by relative path from `src`, so no `dist` rebuild sits between the
mutation and the run.
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 694ec68 -> eb00fb8b831c
× lists only the label change when the draft edits another field of an object with a bound field
× CONTROL: still lists an Options change on a field with inline options
× reads a bound field as unchanged in computeChangeDetail, and keeps a move to or from inline options
AssertionError: expected [ '~ Again notes notes · Label', …(1) ] to have a length of 1 but got 2
AssertionError: expected [ '~ Status status · Options', …(1) ] to have a length of 1 but got 2
AssertionError: expected [ { name: 'tier', …(1) } ] to deeply equal []
Test Files 1 failed (1)
Tests 3 failed (3)
ablation-replace: ok restored: blob == HEAD (694ec68) and `git diff HEAD` is empty
```
Without the fix, case 1 renders a second row beside the label row, and
case 3 lists `tier` as changed. The CONTROL fails only on its row count:
its own `Options` row is still there first, and one more row follows it.
</details>
## Acceptance notes
- **Scope:** this changes only what the review sheet **displays**. The
publish door's own no-op test (content hashes of the stored bodies)
never read served options and is unchanged.
- **`type` gate:** only `object` bodies are normalized. Its one caller
passes `entry.type`. The served picklist form is a property of object
reads.
- No out-of-scope findings.
## Verification
All runs at head `f4d64a21`, from the `ui/` root as `ui/AGENTS.md`
prescribes, under `scripts/pm/os-verify-lock.sh`.
<details><summary>Local runs</summary>
| Command | Verdict |
| --- | --- |
| `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`
(closure) | `VERDICT command-exit 0`, 64/64 tasks |
| `pnpm --filter @object-ui/app-shell build` | `VERDICT command-exit 0`
|
| `pnpm --filter @object-ui/app-shell type-check` | `VERDICT
command-exit 0` |
| `pnpm --dir ui exec vitest run --maxWorkers=2` over the 84 test files
that name `DraftChangesPanel` | `VERDICT command-exit 0`, Test Files 84
passed (84), Tests 536 passed (536) |
| `node scripts/check-sdui-manifest.mjs` | exit 0, manifest intact (106
components) |
| New test file alone | 3 passed (3) |
**Narrowed:** the full `@object-ui/app-shell` vitest suite is not run
locally. It needs more than an hour on the shared box. The 84 files are
every test that names the edited component. CI's Test UI runs the rest.
</details>
<details><summary>Derived gates (`dispatch-gates.mjs
--commands`)</summary>
35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN (`dispatch-gates --ran` exit
0) at `f4d64a21`. Each line is `CMD :: exit N`.
```
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-sdui-manifest.mjs :: exit 0
node scripts/check-sdui-manifest.mjs --self-test :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
```
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfC4w4tZzdzVyV5FGX
Co-authored-by: Claude <noreply@anthropic.com>1 parent 83fc096 commit 40fa433
3 files changed
Lines changed: 206 additions & 3 deletions
File tree
- .changeset
- ui/packages/app-shell/src/preview
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
51 | 56 | | |
52 | 57 | | |
53 | 58 | | |
| |||
664 | 669 | | |
665 | 670 | | |
666 | 671 | | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
667 | 685 | | |
668 | 686 | | |
669 | 687 | | |
| |||
675 | 693 | | |
676 | 694 | | |
677 | 695 | | |
678 | | - | |
679 | | - | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
680 | 701 | | |
681 | 702 | | |
682 | 703 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
0 commit comments