You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8fa370c
Browse filesBrowse the repository at this point in the historyBrowse files
fix(app-shell): refresh a record's activity feed and its count after a save (#30082)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCR7M79mqrrkKsupzFEpuVj)_Fixes#30035
## 维护者速读
- **改了什么:**
记录页(含抽屉)的「动态」在保存后就地重读:编辑弹窗保存、动作、撤销让这条记录失效时,动态的读取跟着重跑,按打开时问过的那几项(讨论、活动行、动态总数)再问一次记录页接口,于是新写的活动行和服务端的计数一起到位,不用刷新。开了
`trackHistory` 的对象,字段变更也跟着重读。
- **为什么改:** 实测是第三种情况:保存确实让本记录的失效计数加一(页脚「更新人 … 刚刚」就是这样刷新的),服务端也在更新返回前就写好了
`sys_activity`(审计写入挂在 `afterUpdate`,引擎 await 完才返回),但动态的读取 effect
根本不看这个计数,只用打开时那份页面负载。
- **风险与回滚:** 只动 `@object-ui/app-shell` 的记录页视图和负载辅助函数,无
spec、无服务端改动,不轮询。每次本记录失效多一个窄请求(`related: []`,只带动态三项)。回滚 = revert。
- **先看:** `RecordDetailView.tsx` 的动态读取 effect(`openedHere` /
`feedStreamsReread` 那段和依赖数组里的
`recordInvalidationNonce`);`recordPagePayload.ts` 新增的
`recordPageStreamsRequestOf`;测试
`RecordDetailView.timelineCount-28737.test.tsx` 末尾的 objectstack#30035
describe。
- **两分钟验证:** `pnpm --dir ui exec vitest run
packages/app-shell/src/views/RecordDetailView.timelineCount-28737.test.tsx`,预期
9/9 通过;在 app-crm 上打开一条商机,编辑 → 改金额 → 更新,不刷新,动态计数加一、列表出现「更新了 商机 …:金额」。
- **需要你做的:** 无,席位落地。
Clause-②: no
Metadata-protocol: none
## What changes
- **The record feed re-reads on this record's invalidation.** The feed
effect in `RecordDetailView` now lists `recordInvalidationNonce` among
its dependencies, so a save through the edit dialog, an action, an undo
or a ⟳ reads the 动态 stream again in place (AGENTS.md #8 / objectui#2269:
refresh data, don't rebuild UI). The record-load effect already re-read
the record on the same nonce; the feed did not, which is the whole
defect.
- **The re-read asks what the open asked.** Once the record changed
after the open (the open's recorded generation no longer equals the
current one), the feed no longer takes its rows from the open's payload.
It asks `getRecordPage` again with
`recordPageStreamsRequestOf(streams)`: the open's own stream parts
(`discussion`, `activity.top`, `timeline`) and nothing else (`related:
[]`, no `explain`, `fieldAccess`, `approvals` or `expand`; those readers
re-read on the same change themselves). The answer flows through the
existing `recordPageComments` / `recordPageActivity` /
`recordPageTimeline` path, so the stream's count stays the server's
(objectstack#28737) instead of falling back to counting held rows, and a
busy record keeps its true total. Every run of the effect at one
invalidation count joins one ask (`feedStreamsReread`). A dataSource
without `getRecordPage` keeps the feed's own `sys_comment` /
`sys_activity` reads, as before.
- **The 动态 tab's window stays held.** A re-read merges the newest rows
into what the feed already holds, so a feed that already read the window
does not set the peek flag again and does not ask the window a second
time.
- **Field changes follow the same nonce.** The `sys_audit_log` read
behind the stream's field-change entries gets `recordInvalidationNonce`
in its dependency list too, so on an object with `enable.trackHistory`
the new field change shows as well.
## Why
The card asked to tell three cases apart. Readings on `origin/main`
`c23501b2b`:
1. **Does the edit dialog's save bump this record's nonce? Yes.** The
dialog's save goes through the adapter (`POST /api/v1/batch` with one
atomic `update` for `crm_opportunity`, measured live), whose
MutationEvent `useMutationInvalidationBridge` (mounted once in
`AppContent`) puts on the bus as `{ objectName, recordId }`. The
record-load effect re-read the record on it, which is why the footer
read 更新人 … 刚刚 while 动态 stayed stale.
2. **Does the server write the activity row after the response? No.**
plugin-audit registers `writeAudit` on `afterUpdate`, which awaits
`persistAuditTrailRow` (the `sys_audit_log` row, then its `sys_activity`
mirror). The engine's update awaits `triggerHooks('afterUpdate', …)`
before it answers. So the row exists when the save answers, and the
late-row case is not real here: no late-row test, no realtime
subscription, no polling.
3. **Does the feed effect depend on the nonce? No — this is the case.**
Its dependency list was `dataSource, objectName, pureRecordId,
currentUser, feedsEnabled, activitiesEnabled, feedFetchKey,
reactionStore, feedWanted, feedPlanReady, feedRetryNonce`; nothing
re-ran it after a write. Even if it had re-run, it would have taken the
rows and the count from the open's payload (`recordPageRead.current`),
which the layout effect does not re-ask on an invalidation.
## Tests
New top-level `describe` in the shared real-adapter harness
`RecordDetailView.timelineCount-28737.test.tsx` (StrictMode, held
transport, the console's mutation bridge mounted beside the view):
- `the record page: the count and the rows hold the save's activity row
without a reload`: open with one activity row and a served count of 2,
the server then holds the update's row and a count of 3,
`ds.update(...)` (the edit dialog's door); the chip reads 3, the new
row's text is on screen, and exactly one `POST …/page` went out with
body `{ related: [], discussion: true, activity: { top: 5 }, timeline: {
… } }`.
- `the drawer: …`: the same with `embedded`.
- `a change to another record of the object reads nothing again`: an
update to `c2` sends no page ask.
<details><summary>Run, ablation (two legs) and the wider suite</summary>
- Harness file: `Test Files 1 passed (1)`, `Tests 9 passed (9)` (the 6
existing objectstack#28737 / #29878 cases plus the 3 above).
- Ablation leg 1, through `scripts/ablation-replace.mjs` (anchor hit 1 →
0, restored blob equal to HEAD, `git diff HEAD` empty): drop
`recordInvalidationNonce` from the feed effect's dependencies → both
save cases red, `AssertionError: expected '2' to be '3'`, `Tests 2
failed | 7 passed (9)`.
- Ablation leg 2: keep the dependency, but take the open's payload on
the re-run (`fromPage = openedHere`) → the same two red, `expected '2'
to be '3'`. So re-running alone is not enough; the re-ask is
load-bearing.
- Every test file under `ui/packages/app-shell/src` and
`ui/apps/console/src` that imports `RecordDetailView` or
`recordPagePayload` (192 files, selected by `grep -rlE`): `Test Files
192 passed (192)`, `Tests 1319 passed (1319)`. The rest of Test UI is
CI's.
- `pnpm --filter @object-ui/app-shell run type-check` (both `tsc
--noEmit` and `tsconfig.test.json`): exit 0. `npx eslint` on the three
touched files from the package dir: 0 errors (134 pre-existing
`no-explicit-any` warnings). `pnpm run check:new-line-citations` (ui):
`0 new citation(s)`.
</details>
## Acceptance notes
- **Live check on `examples/app-crm`** (full build, `pnpm dev:crm --
--fresh`, Console from `ui/apps/console` via Vite so it serves this
branch's src, zh-CN, admin): 商机 record page, 编辑 → 金额 110000 → 115000 →
更新, no reload: 动态 **0 → 1**, row 更新了 商机 "Globex — Analytics Pack":金额 刚刚.
Drawer (`?recordId=` over the list): 动态 **1 → 2** on a second save of
the same record, both rows listed. Before-leg on another record with the
fix ablated (src mutated through `ablation-replace --hold`, restored
after): 动态 **0 → 0** after the save while `GET
/api/v1/data/sys_activity` for that record already answered `total: 1`,
the card's symptom.
- **Extra request per invalidation.** After a save the page now sends
one narrow `POST /data/OBJECT/ID/page` for the streams. The route
answers the record with it (its read gate), so the record is read once
more than strictly needed; the alternative, the feed's own `sys_comment`
+ `sys_comment_reaction` + `sys_activity` reads, is three requests and
loses the served count.
- **Observed, not changed:** on `main` the open's full page payload is
also asked again a moment after a save (the layout effect re-runs on a
plan input that moves after the write; seen in the before-leg too). The
feed does not take it. Noted here only; no defect claimed.
- `origin/main` moved 3 commits since the base (`d79eecfe`, `614e831b`,
`d59a1eb4`); none touches `RecordDetailView.tsx`,
`recordPagePayload.ts`, plugin-detail, data-objectstack or the
record-page route, so the branch was not merged before opening.
## Verification
Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `64c02a63` (change set: 4 paths vs merge
base `c23501b2b`): **35 derived, 35 run, all exit 0**; `dispatch-gates
--ran`: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED".
Among them `node scripts/check-sdui-manifest.mjs` (and `--self-test`),
`check:type-check-debt`, `check:type-check-coverage`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:published-files`, `check:nul-bytes`, `check-changeset-no-major
--base origin/main`, `check-empty-changeset --base origin/main`, `pnpm
--dir ui exec vitest run scripts/`. Dogfood: n/a (no `!` in the title,
no `(narrowing)` arm).
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; eslint on the touched files reports 0 errors.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset
(`@object-ui/app-shell` patch).
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (not touched)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (the 维护者速读 block is
the dispatch convention)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzeXYS62ngjEzQx89tU
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(app-shell): a record's 动态 stream is read again after a save, so its count and rows hold the change without a reload
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
-**The record feed follows the record's invalidation.** A save through the edit dialog (or an action, an undo, a ⟳) put the record on the data-invalidation bus, and the page read the record again, but the 动态 feed kept the rows and the count the page payload answered when it opened. The feed now reads again on the same change, on the record page and in the drawer.
11
+
-**The re-read asks what the open asked.** Once the record changed after the open, the feed asks the record page route for the streams the open asked, and only those (the discussion, the activity rows, the stream's count), so the new activity row and the server's count land in place. The activity row a save writes exists before the save answers, so one ask after the save sees it; nothing polls.
12
+
-**The field changes follow the same change**, so on an object with `enable.trackHistory` the stream's field-change entries are read again too.
0 commit comments