Skip to content

fix(core): a successful operation: 'update' action refreshes the record it wrote - #29970

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-aab444
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-aab444

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29935

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:声明式更新动作(operation: 'update',比如教程里的「Resolve」)在服务器返回成功后,Console 现在会重新读取这条记录:记录页(整页和抽屉)显示写入后的值,visible 条件变为假的按钮随之消失,来源列表里不再符合筛选条件的记录也会离开列表。作者不需要再额外写 refreshAfter: true。
  • 为什么改:spec 会把没写的 refreshAfter 填成 false,Console 拿到的正是这个解析后的形状;服务端动作分派原本只在 refreshAfter !== false 时刷新,所以声明式更新从不刷新。服务器已经写成「已解决」、提示也说成功了,页面却还显示「Open」和「Resolve」按钮,用户会以为失败而重复点击。
  • 风险与回滚:风险低。只改了一处判断:声明式更新成功后发出记录级失效通知(与客户端 dataSource.update 已有的行为一致);其他动作仍按 refreshAfter 决定是否刷新,spec 默认值没有动。回滚即还原本 PR。
  • 先看:ui/packages/core/src/actions/serverActionHandler.ts(shouldRefresh 一行及其注释)、ui/packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx(整页、抽屉、列表、对照组)。
  • 两分钟验证:在仓根运行 pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx packages/core/src/actions/__tests__/serverActionHandler.test.ts,预期 33 个测试全部通过。
  • 需要你做的:无,席位落地。

What changes

Which path the tutorial action takes, measured. An operation: 'update' action never reaches the record page's api handler (the two refreshAfter === true gates in RecordDetailView.tsx that the card names). The spec pins type: 'script' on it, and ActionRunner.dispatchCollected reads operation before type. So it goes to executeUpdateOperation, which hands it to the registered script dispatch. On the record page that dispatch is createConsoleServerActionHandler, which wraps core's createServerActionHandler. That handler refreshed on action.refreshAfter !== false.

Why the two paths look different but behave the same. ActionSchema.safeParse on the card's action, run against packages/spec/src at base 40fa4335, gives {"type":"script","operation":"update","refreshAfter":false}. The spec's .default(false) fills in the key, and the Console is served that parsed shape. With the key present, !== false behaves exactly like === true. The two spellings differ only for a row that reaches the client unparsed. That is why the network capture shows the 200 and no re-read.

The fix. One line in createServerActionHandler:

const shouldRefresh = action.operation === 'update' || action.refreshAfter !== false;

A successful declared update now calls onRefresh. On the record page, onRefresh is notifyRecordChanged, which sends notifyDataChanged({ objectName, recordId }). The console list runtime (useConsoleActionRuntime) passes its own onRefresh to the same factory, so a declared update run from a list row refreshes there too.

Re-read, not apply. The route does answer with the written record ({ ok, action, objectName, recordId, result }). The page does not apply it. The record page reads its record with $expand on every readable relation, and the server strips fields the reader may not read. The route's result is the engine's update answer, which has neither shape. Also, the invalidation bus carries scopes, not rows. So the page re-reads through its own read path.

The list mechanism. A record-scoped change matches every reader that subscribes with useDataInvalidation(objectName) and no record id (dataChangeMatches: "a hook WITHOUT recordId is a list/aggregate reader"). ListView and ObjectView subscribe that way and refetch with their own filter, so the record leaves a list it no longer matches. The drawer is the same RecordDetailView, mounted embedded beside its list. One event refreshes both.

What refreshAfter: false still governs. It still decides the refresh for:

  • every server (script) action that is not a declared update,
  • the record page's api handler (both === true gates are unchanged),
  • the newTabUrl fast path in createConsoleServerActionHandler,
  • the runner's result.reload.

The spec default is untouched. Nothing under packages/spec/** changes, and no lint rule is added.

Docs (ui AGENTS.md #2). Two one-line edits:

  • the onRefresh comment in ui/packages/core/README.md now says it also runs after every operation: 'update';
  • the "Runs but the list looks stale" row in content/docs/ui/actions.mdx now says a declared update re-reads on its own.

Tests

  • New ui/packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx. It mounts the real record page, header, runner and console server-action handler, and only the authenticated fetch is faked. The action is the card's action as the Console is served it, with type: 'script' and refreshAfter: false filled in.
    • Full page. Before the click, the path bar's current stage is "Open, current stage". After the 200, findOne runs again, the current stage is "Resolved, current stage", and Resolve is gone from the header toolbar.
    • Drawer (embedded, mounted next to a list reader that holds the Open filter on useDataInvalidation(objectName)). The same click re-reads the record and removes the Resolve button. The Open list goes from [tkt_1, tkt_2] to [tkt_2].
    • CONTROL. A non-update script action served with refreshAfter: false gets its 200 and its toast, and findOne is not called again.
  • ui/packages/core/src/actions/__tests__/serverActionHandler.test.ts adds two tests:
    • a successful update with refreshAfter: false calls onRefresh once and returns reload: true;
    • a refused update (403) does not call onRefresh.

Acceptance notes

  • The card's order points at RecordDetailView.tsx. That file is not touched, because the tutorial action never reaches the gates there (measured above). The diff stays on the action-success path, in core's dispatch.
  • skills/objectstack-ui/rules/actions.md still says "Set refreshAfter: true whenever the action mutates the current record". That is still harmless, but it is no longer needed for an operation: 'update' action. skills/** is a Tier H surface, so I left it alone. carrier: the ui seat, if it wants a skills follow-up.
  • Reading, not filed: an action that reaches the client unparsed with no refreshAfter key still refreshes through !== false, while the parsed default false does not. This difference was there before this PR and this diff does not change it. The spec's .default(false) is the contract. carrier: none.

Verification

All of it ran on head 61f02c31, in worktree objectstack-issue-29935, under scripts/pm/os-verify-lock.sh.

  • Build first: pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter @objectstack/formula build, VERDICT command-exit 0 (9m51s).
  • The new pins: pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx packages/core/src/actions/__tests__/serverActionHandler.test.ts, VERDICT 0, 2 files and 33 tests passed.
  • Consumer sweep: 114 test files, VERDICT 0, 1629 tests passed. The set is every ui test file that names createServerActionHandler, consoleServerAction or useConsoleActionRuntime and does not mock consoleServerAction, plus all of packages/core/src/actions/__tests__/ and the new file. CI's Test UI runs the rest.
  • cd ui && pnpm exec turbo run build type-check --concurrency=2 --filter=@object-ui/core --filter=@object-ui/app-shell, VERDICT 0, 68/68 tasks. Both type-check scripts ran with their tsconfig.test.json leg, so the test files are compiled too.
  • node scripts/check-sdui-manifest.mjs: exit 0.

Ablation (with scripts/ablation-replace.mjs, committed head; vitest aliases @object-ui/core to src, so no dist rebuild):

  1. I restored the pre-fix line const shouldRefresh = action.refreshAfter !== false;. 3 tests went red: the core update pin, plus the full-page and drawer tests. The other 30 stayed green, including the CONTROL and the refused-update pin. On restore, the blob was dc0a20f3 == HEAD and git diff HEAD was empty.
  2. I put in the === true gate the card names (const shouldRefresh = action.refreshAfter === true;). The full-page and drawer tests went red, and the CONTROL stayed green. The restored blob was dc0a20f3 == HEAD.
Derived gates: dispatch-gates --commands, 67 at 61f02c3, all exit 0; --ran: 67 derived, 67 run, 0 NOT-MEASURED
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-adr-links.mjs :: exit 0
node scripts/check-adr-links.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-doc-route-spelling.mjs --advisory :: exit 0
node scripts/check-doc-route-spelling.mjs --self-test :: exit 0
node scripts/check-docs-section-name.mjs :: exit 0
node scripts/check-docs-section-name.mjs --self-test :: 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/check-section-landing-index.mjs :: exit 0
node scripts/check-section-landing-index.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.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/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0
pnpm --filter @objectstack/spec run check:docs :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:skill-examples :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:corpus-claim-drift :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-anchors :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:doc-frontmatter :: exit 0
pnpm check:docs-audit-scope :: exit 0
pnpm check:docs-redirects :: exit 0
pnpm check:docs-single-h1 :: exit 0
pnpm check:docs-spec-enumerations :: exit 0
pnpm check:docs-transcript-drift :: 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:published-readme-links :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:react-page-adapter-contract :: 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:role-word :: 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:vendor-version-stamps :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

check:skill-examples first exited 3 (PREREQUISITE NOT MET, no client dist). It went green after pnpm --filter '@objectstack/client-react...' build, under the lock, VERDICT 0. pnpm --dir ui exec vitest run scripts/: 170 files passed, 2 skipped (4979 tests).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (CI runs it)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (except the 维护者速读 block the dispatch contract requires)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufziMa1x7KKmd4N1nPLV

The spec materializes `refreshAfter: false` on an action authored without
it, so the server-action dispatch's `refreshAfter !== false` refresh never
fired for a declared update: the route wrote the record and the page kept
showing the old values. A declared update's success is now a record change
whatever `refreshAfter` says; every other dispatch keeps reading the flag.

Claude-Session: https://claude.ai/code/session_01WGufziMa1x7KKmd4N1nPLV
Co-authored-by: Claude <noreply@anthropic.com>
… write

The tutorial's served action (operation: 'update', refreshAfter: false
materialized) on the full page and in the drawer: after the 200 the page
re-reads, the path bar reads Resolved, the Resolve button is gone, and a
list reader of the object drops the record from its Open filter. CONTROL:
a non-update script action served with refreshAfter: false sends no re-read.
Adds the patch changeset for @object-ui/core.

Claude-Session: https://claude.ai/code/session_01WGufziMa1x7KKmd4N1nPLV
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 3, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

维护者速读(席位终稿)

  • 改了什么:声明式更新动作(operation: 'update',比如教程里的「Resolve」)在服务器返回成功后,Console 会重新读取这条记录:整页和抽屉显示写入后的值,visible 变为假的按钮随之消失,来源列表里不再符合筛选条件的记录也离开列表。作者不必再写 refreshAfter: true。
  • 为什么改:spec 把没写的 refreshAfter 填成 false,Console 拿到的就是这个形状;服务端动作分派只在 refreshAfter !== false 时刷新,所以声明式更新从不刷新,用户看到旧状态会重复点击。
  • 风险与回滚:低。只改 createServerActionHandler 里一行判断;其他动作仍按 refreshAfter 决定是否刷新,spec 默认值不动。回滚即还原本 PR。
  • 先看:ui/packages/core/src/actions/serverActionHandler.ts 的 shouldRefresh;RecordDetailView.updateActionRefresh-29935.test.tsx(整页、抽屉、列表、对照组)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx packages/core/src/actions/__tests__/serverActionHandler.test.ts,33 个用例全过。
  • 需要你做的:无,非受管面,CI 全绿后席位落地。
  • 席位意见:ACCEPT。dev 实测出动作真实走的是核心服务端分派而不是派发令猜的 RecordDetailView api 闸门,修在真正的那一处,没碰 spec 默认值,符合分诊划定的范围。

席位核对 · ACCEPT · head 61f02c31791f773c4db233023d410165220c77f8 · 2026-11-03T19:13Z

  1. Shape: draft, base main, attribution block then Fixes #29935 (whole card lands). One changeset (@object-ui/core patch) with Clause-②: no / Metadata-protocol: none; no content/docs/releases/; 6 files, +382/−3 (329 of them the new page-level test). check-governed-merges --pr 29970: NOT governed (0 of 6 paths hit the register), 385 lines ≤ 3000. No contract face; this seat's read is the review of record.
  2. Premise, measured: at merge base 40fa4335, createServerActionHandler (ui/packages/core/src/actions/serverActionHandler.ts ~L310) refreshed on action.refreshAfter !== false; the spec's .default(false) makes that key present on every served action, so a declared update never refreshed. The order's guess (the two refreshAfter === true gates in RecordDetailView.tsx) was falsified by the dev: operation is read before type, so the action takes executeUpdateOperation → the registered script dispatch, not the page's api handler. The diff changes only that line; reload on the result follows the same shouldRefresh. Branch 3 behind main; those commits touch none of the 6 files.
  3. Gates: dev's local union at 61f02c31: new pins 2 files / 33 tests, consumer sweep 114 files / 1629 tests, build + type-check core and app-shell 68/68, dispatch-gates --commands 67/67 exit 0 with --ran 0 NOT-MEASURED, check-sdui-manifest exit 0. Ablation twice: the pre-fix line turns the core pin, full page and drawer red; the refreshAfter === true gate turns full page and drawer red; CONTROL green both times, blob restored. CI on this head was in progress at this reading; landing waits for every check green or expected-skip.
  4. Accepted deviation: the order said "use the returned record if the page can apply it, otherwise re-read"; the dev re-reads (record-scoped notifyDataChanged), because the page has no apply path for the route's result. That also refetches every list of the object, which is how the filtered list drops the row. Accepted. Two doc lines (content/docs/ui/actions.mdx troubleshooting row, ui/packages/core/README.md onRefresh comment) describe the new behaviour. Accepted.
  5. Out of scope: packages/spec/** and the refreshAfter default untouched; no lint rule (triage's separate slices). skills/objectstack-ui/rules/actions.md still recommends refreshAfter: true on record-mutating actions; that advice stays harmless (an explicit true still refreshes) and is a Tier H skills edit, not owed here. mcp_calls: 1 read, no write tool.
  6. Landing: ordinary queue. When every check on this head is green or expected-skip: relay pr_ready + automerge_enable, follow to MERGED, record the landing on An operation: 'update' action without refreshAfter: true writes the record but the Console keeps showing the old values and the action button, and os validate passes it #29935.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 19:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 3, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 3, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 3, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 3, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 3, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit c2be734 Nov 3, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-aab444 branch November 3, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant