Uh oh!
There was an error while loading. Please reload this page.
fix(form): bind previous for field rules and stop resubmitting read-only fields - #3518
Conversation
…-only fields
An edit form only honoured HALF of `readonlyWhen` (objectui#3484):
- a predicate over `record.*` locked the field correctly;
- a predicate over `previous.*` did NOT — the client CEL scope never had a
`previous` root, so the expression faulted and fell back to the fail-open
default. The field rendered as an ordinary input on a record the author had
declared frozen: the user could change it, submit, and watch the change
disappear (server strips it, save reports success). Silent data loss.
`@object-ui/core`'s `resolveFieldRuleState` has always taken a `previous`
record — no producer ever passed one. Add `FormSchema.previousValues`, set it
from the record every edit-mode host has already read (ObjectForm, ModalForm,
DrawerForm, SplitForm, TabbedForm, WizardForm), and thread it through the
renderer. It also seeds the `record` binding underneath the live values, the
way objectql builds its own scope (`merged = { ...previous, ...data }`), so a
predicate may name a column the form does not render.
Its ABSENCE is what marks an insert, which matters for the second half: an
edit form now drops the keys it resolved to read-only before submitting.
react-hook-form keeps a value for every registered field, so a form seeded from
a full record round-tripped the WHOLE record — including fields it had just
drawn as read-only text. The server stripped them and answered `droppedFields`,
and the shell turned that into "some fields were not saved" on a save that lost
nothing. The strip is gated on edit because objectql exempts INSERT.
The warning channel is tightened at both ends:
- the adapter drops a reported strip whose value equals what the record
already holds (proven against the echoed record; unprovable ⇒ still warn);
- the toast names fields by LABEL rather than API key, and says the record was
saved — it used to sit beside the save surface's own success toast reading
as a flat contradiction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The write-warning test was green alone and RED in a full run: 6 failures, all
of the shape "expected toast.warning to be called 1 times, but got 0", plus the
one assertion that checks it is NOT called passing. Deterministic, not flaky.
The file is a `.ts`, so it runs in the `unit` project — which sets
`isolate: false` (one module graph per worker, see vitest.config.mts). A
`vi.mock('sonner')` there only holds if no other file in the same worker
imported the real module first, so the provider took the real `toast` while the
test watched the mock. Every other `vi.mock('sonner')` suite in this repo is a
`.tsx` in the isolated `dom` project; this was the lone exception.
Rather than move the file to where the mock happens to work, remove the need
for one. The message builder moves to its own module, `writeWarningToast.ts`,
which imports nothing that renders and takes the toast sink as a REQUIRED
parameter — a default-to-sonner would reintroduce the very import that has to
stay out. `AdapterProvider` passes sonner's `toast`; the test passes a recorder
and asserts on it. Behaviour is unchanged, and the suite no longer depends on
worker placement or on vitest project configuration at all.
Also drops the "exported for its unit test only" escape hatch on
`toastWriteWarning` — the seam is now the parameter, so the provider keeps its
own surface, and adds a case for the no-adapter path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The latest updates on your projects. Learn more about Vercel for GitHub. |
baozhoutao
commented
Aug 7, 2026
浏览器实测证据(独立端口栈,非单测)栈:framework 夹具(仅测试脚手架,⛔ 未随本 PR 提交):在一次性 framework worktree 里给 记录:INV-1005( 1. 编辑弹窗 DOM 读数字段容器
修复前: 2. 提交 payload( |
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…donly-when-form # Conflicts: # packages/components/src/renderers/form/form.tsx
…objectstack-ai#3387) (objectstack-ai#3769) objectstack#4731 / #4843 把「哪些前端改动发版」的判据统一成**读本仓声明的 `.changeset/*.md`**,而这个判据赖以成立的前提——改了发版包源码就必须带一个 changeset——此前由任何门禁保证。实测的后果:`19716b5bf` fix(charts)、 `5e7ef1141` fix(i18n)、`0e50440`(objectstack-ai#3518,26 个文件跨五个包加十个语言包) 都改了已发布包的源码、都是用户可见修复、都没有 changeset,于是搭着下一次发版 出去,在 CHANGELOG、版本号、平台发布记录里一处都查不到。 新增正向触发门禁 `.github/workflows/changeset-presence.yml` (`scripts/check-changeset-presence.mjs`):改动落在发版包的 `src/` 上时, 本次改动必须**新增**一个 `.changeset/*.md`。 ⛔ 没有加宽 `changeset-guard.yml` 的 paths。它的 `paths: ['.changeset/**']` 是刻意的反向触发,并写在自己的文件头里:`ci.yml`/`lint.yml` 都把 `.changeset/**` 列进 `paths-ignore`,只加 changeset 的 PR 不会启动任何别的 workflow,那个 guard 就是为看见这种 PR 而存在的。而**忘了写 changeset 的 PR 按定义不碰 `.changeset/**`**——唯一能发现它的检查恰好是唯一不会跑的检查。 加宽会毁掉它原本要服务的场景,所以两个门禁并存、方向相反:一个管已有声明的 级别,一个管声明是否存在。 几处判断,连同得出它的测量: - **空 frontmatter 是一等通过写法**,不是变通。要的是「声明一次」,不是强制 发版;纯内部改动/只动测试写 `---` 紧跟 `---` 加一句理由即可,理由就留在仓 库里。因此也**没有**为 `src/` 下的测试文件开豁免口子——教门禁认哪些文件 「不算」正是漏洞的藏身处;顺带一个实测反例:`f1310e40f` 是 `test(...)` 前 缀却同时改了非测试源码,提交信息的前缀并不可信,文件清单才可信。 - **守护面是推导出来的,不是写死的 glob。** issue 提的字面 glob 只覆盖 `packages/` 下一层,而 `@object-ui/console` 在 `apps/console`——本仓最常改 的已发布包,也正是平台 `bump-objectui.sh` 替它写 changeset 的那个包——会被 整整漏掉。改为读 `.changeset/config.json` 的 `fixed` 组:发版覆盖谁,门禁 就守谁,`ignore` 的(`@object-ui/site`、examples)不守。今天推导出 40 个包 目录。既不在 `fixed` 也不在 `ignore` 的包,其源码改动**响亮失败**而不是被 当成「不发版」,分类本身由 `check-changeset-fixed.mjs` 负责。 - **触发器上不加任何 path 过滤。** trigger 上的 `paths` 会跳过整个 workflow (GitHub 没有 per-job path filter),于是不匹配的 PR 根本不会**创建**这个 check;而一个从不上报的必需 check 不会让 PR 失败,只会让它永远 pending, 在合并队列里则要等 ruleset 的 60 分钟超时——这正是 objectstack-ai#3523 的后半段。所以本 门禁在每个 PR 上都上报、由脚本读 diff 决定,并因此**可以**被设为必需,同时 订阅 `merge_group`(`merge-queue-reporting.test.ts` 的名单加了这一条)。 过滤器还会成为脚本守护面的第二份副本,和它自由漂移。 - **push 到 main 不订阅**:改动已经落地,没有还能写的声明,失败只会把 main 染红在下一位提交者头上。`workflow_dispatch` 也不订阅:手动跑没有可判的 revision range,而本门禁宁可响亮失败也不肯自己编一个。 - **每一项缺失输入都响亮失败**(#4690 / objectstack#4928):base 解析不出、 `git diff` 报错、`.changeset/` 目录不存在、包未分类,全部红。方向和 `ci.yml` 里的过滤门禁**相反**:那些决定要不要跑活,「判断不了」就跑;这里 判断本身就是活,「判断不了」就失败。两者都拒绝在什么都没看的情况下报绿。 自身写测过程中被自己的测试抓出一个真实缺陷并修掉:`resolveBaseRef` 原先把 显式 `--base` 只当作候选链的第一环,于是一个在本地 clone 里不存在的 sha 会 静默跌落到 `merge-base with main`,拿**另一个**提交做比较并打印自信的绿灯 (实测 exit 0;修好后 exit 1)。「你指的 base 不存在」和「你没指 base」是 两件不同的事,只有后者可以靠猜回答。(同族的 `check-i18n-en-drift.mjs` 仍是跌落写法,已另开单,本 PR 不动。) 反向验证(先预判方向再跑):去掉 `--diff-filter=A` → 「编辑他人待发 changeset」用例转红(1 failed / 30 passed);恢复上述 base 跌落 → 显式 base 用例转红且 exit 0→1;把 `git diff` 失败吞成空列表 → diff 失败用例转红。第一 项的预判**错了一次并已改正**:原先声称覆盖该 filter 的 "pending" 用例在去掉 filter 后依然全绿——早提交的 changeset 本就落在 diff range 之外,那条用例钉 的是 range 而非 filter。补了真正触达 filter 的两个 fixture,其中「删除待发 changeset」经测量由两道独立防线各自挡住,注释按实测改写。 三处文档会因本门禁变成假话,一并修正:`ci-cd-pipeline.md` 里 「Nothing in CI requires a pull request to add a changeset」(该页被 `ci-cd-pipeline-doc.test.ts` 双向钉住,新 workflow 本就必须在此建节)、 `CONTRIBUTING.md` 的「DON'T create a changeset for ... apps / 测试改动」、 以及 AGENTS.md 那句「纯 bug 修复不需要」——正是这条旧判据放走了上面三条修复。 这页自己的教训就是:一个把 CI 实际强制内容说错的文档比没有文档更糟。 无 changeset:CI 配置 + 仓库级脚本 + 文档,不改任何已发布包源码,与 objectstack-ai#3722 / objectstack-ai#3744 同例。本 PR 也是自指的冒烟测试——门禁在自己的改动上判为「不欠 changeset」 并通过(实测 7 个文件,0 个落在守护面内)。 Fixesobjectstack-ai#3387 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude <noreply@anthropic.com>
Fixes#3484
问题
编辑表单只认一半
readonlyWhen:record.*→ 正确锁住;previous.*→ 没锁。客户端 CEL 作用域里从来没有previous这个根,表达式求值失败,退回 fail-open 默认值。作者声明为冻结的字段渲染成普通可编辑控件:用户改了、提交了,改动消失(服务端剥离,保存却报成功)。静默数据丢失。@object-ui/core的resolveFieldRuleState一直接受previous参数 —— 缺的是没有任何生产侧调用方传它。这是「生产者缺失」,不是求值器缺陷。改法
1. 补齐生产者(消掉静默数据丢失)
新增
FormSchema.previousValues,由每个 edit 模式宿主(ObjectForm/ModalForm/DrawerForm/SplitForm/TabbedForm/WizardForm)把它本来就已经读到的记录传下去,渲染器透传给resolveFieldRuleState/evalFieldPredicate。它同时垫在实时表单值下面构成
record绑定,与服务端自己的作用域一致(objectqlstripReadonlyWhenFields的merged = { ...previous, ...data }),所以谓词可以引用表单没渲染的列。WizardForm单独存了一份persistedRecord:它的formData会被逐步合并各步答案,第一步之后就不再是原始行了。2. 提交剔除只读键(消掉假告警)
react-hook-form 为每个注册字段保留值,所以从整条记录 seed 的编辑表单在保存时把整条记录回传 —— 包括它自己刚渲染成只读纯文本的字段。服务端剥离它们并回
droppedFields,console 于是在一次什么都没丢的保存上弹「部分字段未保存」。现在编辑表单在提交前剔掉自己判定为只读的键。
以
previousValues是否存在来区分 insert/update:objectql 明确豁免 INSERT 不做readonlyWhen剥离,所以 create 路径不剔除 —— 否则客户端会比服务端更严,静默吃掉带默认值的字段。3. 空操作不告警
ObjectStackAdapter拿请求 payload 与响应回显的记录做值比对,值相同的剥离直接丢弃(单条 +batchTransaction两条路径)。无法证明是空操作时(响应没回显该键)仍然告警 —— 严格比较,1不等于'1',宁可多报不可漏报。4/5. Toast 合流 + label 化
Saved — but some fields did not take effect:它与保存面自己的成功 toast 并列,原文案「部分字段未保存」读起来是直接矛盾。这是一个结果,不是两个。10 个 locale 全部更新,key 不变。消息组装拆到独立模块
providers/writeWarningToast.ts,不 import 任何渲染相关的东西,toast sink 作为必填入参注入(AdapterProvider传 sonner 的toast)。给它一个默认值会把 sonner 重新拖进模块图,正是要避免的那条依赖 —— 详见下面「测试」。范围
单仓落地,不开 objectstack companion。服务端回
droppedFields语义上没说谎(客户端确实送了那些键,引擎确实没写);错的是 console 拿这个信号无条件弹「未保存」。第 2 条落地后,该路径上服务端根本不会再剥离任何东西。测试
单测 32 条(新增/修改),全部先验证过修复前红:
form-readonly-when-previous.test.tsx—previous.*口径生效、prior 值(不是实时值)说了算、无previousValues时不锁(INSERT 语义)、record.*覆盖表单未渲染的列;form-readonly-submit-strip.test.tsx— payload 不再回传只读键、谓词为假时不剔除、create 不剔除;onWriteWarning.test.ts— 空操作抑制(单条 + batch)、真丢失仍告警且只列真丢的字段、无法证明时保留告警、1不等于'1';writeWarningToast.test.ts— label 化、翻译包路由、schema 缺失回退、元数据读取失败仍出提示、无 adapter 回退、合流文案。writeWarningToast.test.ts的 sink 是入参而非 mock,原因值得记一笔:vitest.config.mts的unitproject 用isolate: false(每 worker 共享一份模块图),.ts测试里vi.mock('sonner')只在同 worker 没有别的文件先拉真 sonner 时才成立 —— 单包跑绿、全量跑红,且报错(mock.calls[0] is undefined)完全不指向真因。把 sink 变成参数后,这个测试与 vitest 的隔离设置彻底无关。全量(受影响包,连跑两次均绿):
type-check 30/30 successful;lint 0 errors。
浏览器实测证据见本 PR 下方评论(showcase 独立端口栈,
previous.*/record.*两种口径的修复前后 DOM 读数 + PATCH body + toast 对照)。纯 bug 修复,无 changeset。
🤖 Generated with Claude Code