Skip to content

fix(form): bind previous for field rules and stop resubmitting read-only fields - #3518

Merged
baozhoutao merged 4 commits into
mainfrom
claude/issue-3484-readonly-when-form
Aug 7, 2026
Merged

fix(form): bind previous for field rules and stop resubmitting read-only fields#3518
baozhoutao merged 4 commits into
mainfrom
claude/issue-3484-readonly-when-form

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes#3484

问题

编辑表单只认一半 readonlyWhen:

  • 谓词写 record.* → 正确锁住;
  • 谓词写 previous.*没锁。客户端 CEL 作用域里从来没有 previous 这个根,表达式求值失败,退回 fail-open 默认值。作者声明为冻结的字段渲染成普通可编辑控件:用户改了、提交了,改动消失(服务端剥离,保存却报成功)。静默数据丢失。

@object-ui/coreresolveFieldRuleState 一直接受 previous 参数 —— 缺的是没有任何生产侧调用方传它。这是「生产者缺失」,不是求值器缺陷。

改法

1. 补齐生产者(消掉静默数据丢失)

新增 FormSchema.previousValues,由每个 edit 模式宿主(ObjectForm / ModalForm / DrawerForm / SplitForm / TabbedForm / WizardForm)把它本来就已经读到的记录传下去,渲染器透传给 resolveFieldRuleState / evalFieldPredicate

它同时垫在实时表单值下面构成 record 绑定,与服务端自己的作用域一致(objectql stripReadonlyWhenFieldsmerged = { ...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 不变。
  • 字段用 label 而不是裸 API 名:从 adapter 缓存的对象 schema 取 label,再过一遍与其它界面相同的约定式 i18n;schema 里查不到就退回 API 名(精确的 key 好过瞎猜的 label)。

消息组装拆到独立模块 providers/writeWarningToast.ts,不 import 任何渲染相关的东西,toast sink 作为必填入参注入(AdapterProvider 传 sonner 的 toast)。给它一个默认值会把 sonner 重新拖进模块图,正是要避免的那条依赖 —— 详见下面「测试」。

范围

单仓落地,不开 objectstack companion。服务端回 droppedFields 语义上没说谎(客户端确实送了那些键,引擎确实没写);错的是 console 拿这个信号无条件弹「未保存」。第 2 条落地后,该路径上服务端根本不会再剥离任何东西。

测试

单测 32 条(新增/修改),全部先验证过修复前红:

  • form-readonly-when-previous.test.tsxprevious.* 口径生效、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.mtsunit project 用 isolate: false(每 worker 共享一份模块图),.ts 测试里 vi.mock('sonner') 只在同 worker 没有别的文件先拉真 sonner 时才成立 —— 单包跑绿、全量跑红,且报错(mock.calls[0] is undefined)完全不指向真因。把 sink 变成参数后,这个测试与 vitest 的隔离设置彻底无关。

全量(受影响包,连跑两次均绿):

pnpm exec vitest run packages/components/ packages/plugin-form/ packages/data-objectstack/ packages/app-shell/ packages/i18n/ packages/types/ --maxWorkers=2
Test Files 481 passed (481)
Tests 4435 passed | 1 skipped (4436)

type-check 30/30 successful;lint 0 errors。

浏览器实测证据见本 PR 下方评论(showcase 独立端口栈,previous.* / record.* 两种口径的修复前后 DOM 读数 + PATCH body + toast 对照)。

纯 bug 修复,无 changeset。

🤖 Generated with Claude Code

baozhoutaoand others added 2 commits August 6, 2026 11:23
…-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>
@vercel

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 7, 2026 4:05am

Request Review

@baozhoutao

Copy link
Copy Markdown
ContributorAuthor

浏览器实测证据(独立端口栈,非单测)

:framework examples/app-showcaseobjectstack dev --seed-admin --fresh -p 4610,
配本分支 worktree 的 console — vite --port 5610(vite 把 workspace 包 alias 到 src,无需 rebuild)。

夹具(仅测试脚手架,⛔ 未随本 PR 提交):在一次性 framework worktree 里给 showcase_invoice.region 加了
readonlyWhen: Pprevious.status != 'draft'showcase_invoice.tax_rate本来就带readonlyWhen: Precord.status == 'paid'。验完 worktree 与分支已删。

记录:INV-1005(YPf8rKPCpx0U8YmT),status = paid。


1. 编辑弹窗 DOM 读数

字段容器 [data-testid="field:showcase_invoice.{f}"]input,textarea,select,[role=combobox] 的计数:

字段修复前修复后
region(previous.* 口径){controls: 2, "RegionAMERAMEREMEAAPAC"}{controls: 0, "RegionAMER"}
tax_rate(record.* 口径){controls: 0, "税率 (%)8"}{controls: 0, "税率 (%)8"}
status(本就可编辑){controls: 2}{controls: 2}
name(本就可编辑){controls: 1}{controls: 1}

修复前:previous.* 锁定的字段是活的 combobox,展开可选 ["AMER","EMEA","APAC"],能改成 EMEA。
修复后:0 个控件、纯文本 —— 与 record.* 口径表现一致。

2. 提交 payload(POST /api/v1/batch,主从保存路径)

修复前(用户已把 region 从 AMER 改成 EMEA):

{"operations":[{"object":"showcase_invoice","action":"update","id":"YPf8rKPCpx0U8YmT",
"data":{"tax_rate":8,"name":"INV-1005-A","account":"gPPf-7iL3WS7GY5F","contact":null,
"owner":"ada@example.com","region":"emea","status":"paid",
"issued_on":"2026-06-22","paid_on":"2026-06-27"}}],"atomic":true}

响应:"droppedFields":[{"object":"showcase_invoice","fields":["tax_rate","region"],"reason":"readonly_when","index":0}]

落库后 region 仍是 "amer" —— 用户的 EMEA 改动被静默丢弃。而 tax_rate: 8 是表单自己渲染成只读纯文本的字段被原样回传。

修复后(同一记录、同一弹窗,只改发票号):

{"operations":[{"object":"showcase_invoice","action":"update","id":"YPf8rKPCpx0U8YmT",
"data":{"name":"INV-1005-B","account":"gPPf-7iL3WS7GY5F","contact":null,
"owner":"ada@example.com","status":"paid",
"issued_on":"2026-06-22","paid_on":"2026-06-27"}}],"atomic":true}

响应:droppedFieldsregiontax_rate 均不再发送。

3. Toast

  • 修复前 —— 两条,互相违背,且是裸 API 名:
    • 部分字段未保存 / 以下字段在记录当前状态下不可编辑,未保存:tax_rate, region
    • 发票更新成功
  • 修复后 —— 一条:
    • 发票更新成功

4. 真丢失仍然告警(label 化 + 合流文案)

走表单之外的写入路径(集成/脚本会走的那条),用 app 内活的 adapter:

adapter.update('showcase_invoice',id,{tax_rate: 15,region: 'apac'})

→ ⚠ 已保存,但部分字段未生效 / 以下字段在记录当前状态下不可编辑,未生效:税率 (%), Region

字段是 label 而非 tax_rate, region;标题承认「已保存」,不再与成功 toast 打架。

5. 空操作抑制 —— 直接在 adapter 事件通道上量,不受 toast 时序干扰

constevents=[];adapter.onWriteWarning(e=>events.push(e));awaitadapter.update(obj,id,{tax_rate: 8,region: 'amer'});// == 库里现值awaitadapter.update(obj,id,{tax_rate: 21,region: 'apac'});// != 库里现值{warningsAfterNoOpWrite: 0,warningsAfterRealWrite: 1}

⛔ 按本仓规则,截图与实测脚本一律留在 /tmp,不进仓库;临时后端(:4610)/vite(:5610)已按 PID 停掉,--fresh 临时库随进程自动清。

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BLSDjZIO.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)24.58KB7.04KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)480.45KB105.57KB
core (index.js)2.47KB0.91KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)229.82KB56.53KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.03KB28.88KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.01KB27.07KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)185.25KB49.08KB
plugin-kanban (index.js)48.03KB13.22KB
plugin-list (index.js)104.72KB25.36KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-Dex_q9mH.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)480.45KB105.57KB
core (index.js)2.47KB0.91KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)229.82KB56.53KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.03KB28.88KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.01KB27.07KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)185.25KB49.08KB
plugin-kanban (index.js)48.03KB13.22KB
plugin-list (index.js)104.72KB25.36KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-CfE54_8n.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)480.68KB105.60KB
core (index.js)2.47KB0.91KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)229.82KB56.53KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.03KB28.88KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)185.25KB49.08KB
plugin-kanban (index.js)48.03KB13.22KB
plugin-list (index.js)104.72KB25.36KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@baozhoutao
baozhoutao added this pull request to the merge queueAug 7, 2026
Merged via the queue into main with commit 0e50440Aug 7, 2026
18 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-3484-readonly-when-form branch August 7, 2026 05:08
…donly-when-form
# Conflicts:
#	packages/components/src/renderers/form/form.tsx
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant

@baozhoutao