Skip to content

Commit 8fa370c

Browse 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>
1 parent 29cbfcc commit 8fa370c

4 files changed

Lines changed: 153 additions & 8 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
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.

‎ui/packages/app-shell/src/views/RecordDetailView.timelineCount-28737.test.tsx‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ vi.mock('sonner', () => ({
4242
}),
4343
}));
4444

45-
import { MetadataCtx } from '@object-ui/react';
45+
import { MetadataCtx, useMutationInvalidationBridge } from '@object-ui/react';
4646
import { RelatedCountStore } from '@object-ui/components';
4747
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
4848
import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable';
@@ -128,6 +128,7 @@ function pageAnswer(id: string, body: any, answers: Answers) {
128128
}
129129

130130
function answerFor(h: Held, answers: Answers): unknown {
131+
if (h.method === 'PATCH') return { success: true, data: { object: OBJECT, id: 'c1', record: { id: 'c1', name: 'Contact c1', account: 'a1', ...h.body } } };
131132
const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path);
132133
if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null };
133134
const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path);
@@ -184,7 +185,14 @@ beforeEach(() => {
184185
});
185186
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
186187

187-
async function boot(answers: Answers = {}, entry = `/apps/demo/${OBJECT}/record/c1`, objects: readonly unknown[] = ALL) {
188+
// The console's bridge from a dataSource write to the invalidation bus
189+
// (`AppContent` mounts it once): the edit dialog's save reaches the page by it.
190+
function Bridge({ dataSource }: { dataSource: unknown }) {
191+
useMutationInvalidationBridge(dataSource);
192+
return null;
193+
}
194+
195+
async function boot(answers: Answers = {}, entry = `/apps/demo/${OBJECT}/record/c1`, objects: readonly unknown[] = ALL, embedded = false) {
188196
const { fetch, held } = heldTransport(answers);
189197
vi.stubGlobal('fetch', fetch);
190198
const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false });
@@ -198,7 +206,8 @@ async function boot(answers: Answers = {}, entry = `/apps/demo/${OBJECT}/record/
198206
<React.StrictMode>
199207
<MemoryRouter initialEntries={[entry]}>
200208
<MetadataCtx.Provider value={metadata}>
201-
<RecordDetailView dataSource={ds as never} objects={objects as never} onEdit={() => {}} objectNameOverride={OBJECT} recordIdOverride="c1" />
209+
<Bridge dataSource={ds} />
210+
<RecordDetailView dataSource={ds as never} objects={objects as never} onEdit={() => {}} objectNameOverride={OBJECT} recordIdOverride="c1" embedded={embedded} />
202211
</MetadataCtx.Provider>
203212
</MemoryRouter>
204213
</React.StrictMode>,
@@ -299,3 +308,61 @@ describe('objectstack#29878 — the timeline ask counts the field changes of a t
299308
expect(page.body.timeline).toEqual({ discussion: true, activity: true, fieldChanges: false });
300309
});
301310
});
311+
312+
/**
313+
* objectstack#30035 — a save through the edit dialog (the adapter's `update`,
314+
* whose MutationEvent the console's bridge puts on the invalidation bus) reads
315+
* the 动态 stream again: its count and its rows hold the activity row the save
316+
* wrote, with no reload, on the record page and in the drawer. The server
317+
* writes that row inside the update (plugin-audit's `afterUpdate` writer, which
318+
* the engine awaits before it answers), so the answer to one ask after the
319+
* save holds it; nothing polls.
320+
*/
321+
describe('objectstack#30035 — the 动态 stream is read again after a save', () => {
322+
const UPDATED = { ...ACTIVITY, id: 'ac2', type: 'updated', summary: 'changed the amount', timestamp: '2026-10-03T10:00:00.000Z' };
323+
324+
async function saveAndRead(embedded: boolean) {
325+
const answers: Answers = { activity: { total: 1, records: [ACTIVITY] }, timeline: { total: 2 } };
326+
const { held, open, ds } = await boot(answers, undefined, ALL, embedded);
327+
const view = open();
328+
await settle(held);
329+
expect(chipOf(view.container)).toBe('2');
330+
expect(view.container.textContent).not.toContain('changed the amount');
331+
const before = held.length;
332+
333+
// The server's state after the save: the update wrote its activity row.
334+
answers.activity = { total: 2, records: [UPDATED, ACTIVITY] };
335+
answers.timeline = { total: 3 };
336+
await act(async () => { void ds.update(OBJECT, 'c1', { name: 'Renamed' }); });
337+
await settle(held);
338+
return { view, after: held.slice(before) };
339+
}
340+
341+
for (const [surface, embedded] of [['the record page', false], ['the drawer', true]] as const) {
342+
it(`${surface}: the count and the rows hold the save's activity row without a reload`, async () => {
343+
const { view, after } = await saveAndRead(embedded);
344+
expect(chipOf(view.container)).toBe('3');
345+
expect(view.container.textContent).toContain('changed the amount');
346+
// One ask of the streams the open asked, and only those: no related
347+
// list, verdict, projection or approval rides it.
348+
const rereads = after.filter(isPage);
349+
expect(rereads).toHaveLength(1);
350+
expect(rereads[0].body).toEqual({
351+
related: [],
352+
discussion: true,
353+
activity: { top: 5 },
354+
timeline: { discussion: true, activity: true, fieldChanges: false },
355+
});
356+
});
357+
}
358+
359+
it('a change to another record of the object reads nothing again', async () => {
360+
const { held, open, ds } = await boot({ activity: { total: 1, records: [ACTIVITY] }, timeline: { total: 2 } });
361+
open();
362+
await settle(held);
363+
const before = held.length;
364+
await act(async () => { void ds.update(OBJECT, 'c2', { name: 'Other' }); });
365+
await settle(held);
366+
expect(held.slice(before).filter(isPage)).toEqual([]);
367+
});
368+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ import {
4545
recordPageComments,
4646
recordPageRelatedLists,
4747
recordPageRequestOf,
48+
recordPageStreamsRequestOf,
4849
recordPageTimeline,
4950
seedRecordPageReaders,
51+
type RecordPageStreamsAsk,
5052
} from './recordPagePayload.js';
5153
import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js';
5254
import { RECORD_DETAIL_TAB_PARAM, RECORD_DRAWER_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_RELATED_ENLARGE_PUSHED_STATE as RELATED_ENLARGE_PUSHED, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js';
@@ -1220,7 +1222,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
12201222
*/
12211223
activityRows: number;
12221224
timeline: boolean;
1225+
/**
1226+
* objectstack#30035 — the stream parts the open asked, as asked: the
1227+
* feed's re-read after a change to this record asks these again.
1228+
*/
1229+
streams: RecordPageStreamsAsk;
12231230
} | null>(null);
1231+
// objectstack#30035 — the feed's re-read of the streams the open asked, once
1232+
// this record changed after the open: one ask per record and invalidation
1233+
// count, joined by every later run of the feed effect at that count.
1234+
const feedStreamsReread = useRef<{ feedKey: string; generation: number; payload: Promise<RecordPagePayload | null> } | null>(null);
12241235
// objectstack#26589 — the record this viewer just decided an approval on,
12251236
// as `objectName` + `recordId`. A pending approver with no standing access
12261237
// reads the record only while their step waits (objectstack#26180): the
@@ -2469,6 +2480,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
24692480
activity: plan.activity !== undefined,
24702481
activityRows: plan.activity ?? 0,
24712482
timeline: plan.timeline !== undefined,
2483+
streams: { discussion: plan.discussion, activity: plan.activity, timeline: plan.timeline },
24722484
};
24732485
seedRecordPageReaders({ payload, objectName, recordId: pureRecordId, principal: recordPagePrincipal, ...plan });
24742486
}, [recordPagePlan, objectName, pureRecordId, dataSource, recordPagePrincipal]);
@@ -2884,8 +2896,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
28842896
// `t` is identity-stable per language; `language` refires the effect on a
28852897
// language switch so the translated values re-localize, and
28862898
// `displayLocale` on a display-locale switch so the dates do.
2899+
// `recordInvalidationNonce` (objectstack#30035): a change to this record
2900+
// reads its field changes again, the 动态 stream's other source.
28872901
// eslint-disable-next-line react-hooks/exhaustive-deps
2888-
}, [dataSource, pureRecordId, objectDef, historyEnabled, historyWanted, objects, language, displayLocale, tenantCurrency]);
2902+
}, [dataSource, pureRecordId, objectDef, historyEnabled, historyWanted, objects, language, displayLocale, tenantCurrency, recordInvalidationNonce]);
28892903

28902904
// Fetch a directory of active users once per dataSource mount and expose
28912905
// them as @-mention suggestions to the DiscussionContext. Capped at 50 to
@@ -3084,7 +3098,31 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
30843098
const retrying = feedRetryRef.current.get(threadId) ?? null;
30853099
if (retrying) feedRetryRef.current.delete(threadId);
30863100
const opened = recordPageRead.current;
3087-
const fromPage = !retrying && opened && opened.feedKey === threadId ? opened : null;
3101+
const openedHere = !retrying && opened && opened.feedKey === threadId ? opened : null;
3102+
// objectstack#30035 — a change to THIS record since the open (a save
3103+
// through the edit dialog, an action, an undo: `recordInvalidationNonce`
3104+
// re-runs this effect) leaves the open's payload stale, and the rows the
3105+
// change wrote are not in it. The streams the open asked are asked again,
3106+
// as asked, so the new rows AND the stream's count both come back from the
3107+
// server, read the way the open read them. The activity row a save writes
3108+
// exists before the save answers (the audit writer runs in the update's
3109+
// awaited `afterUpdate` hook), so this one ask after the change sees it:
3110+
// nothing polls. Every run at one invalidation count joins one ask.
3111+
const generation = recordPageGenerationRef.current;
3112+
let fromPage = openedHere;
3113+
if (openedHere && openedHere.generation !== generation) {
3114+
if (typeof dataSource.getRecordPage === 'function') {
3115+
const held = feedStreamsReread.current;
3116+
const payload = held && held.feedKey === threadId && held.generation === generation
3117+
? held.payload
3118+
: dataSource.getRecordPage(objectName, pureRecordId, recordPageStreamsRequestOf(openedHere.streams));
3119+
feedStreamsReread.current = { feedKey: threadId, generation, payload };
3120+
fromPage = { ...openedHere, payload };
3121+
} else {
3122+
fromPage = null;
3123+
}
3124+
}
3125+
const rereading = fromPage !== null && fromPage !== openedHere;
30883126
// The two reads below run in PARALLEL and are collected here so the
30893127
// loading flag can close over BOTH of them — see the `allSettled` at the
30903128
// end of this effect.
@@ -3289,11 +3327,18 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
32893327
recordVerdict('activity', 'answered');
32903328
// objectstack#28737 — the payload asked only the 动态 panel's rows and
32913329
// the record holds more: the 动态 tab reads the window when it opens.
3292-
const peek = !!fromPage?.activity
3330+
const answeredPeek = !!fromPage?.activity
32933331
&& fromPage.activityRows < RECORD_PAGE_ACTIVITY_ROWS
32943332
&& typeof res?.total === 'number'
32953333
&& res.total > (res?.data?.length ?? 0);
3296-
setActivityPeekByRecord(prev => ((prev[threadId] ?? false) === peek ? prev : { ...prev, [threadId]: peek }));
3334+
setActivityPeekByRecord(prev => {
3335+
const held = prev[threadId] ?? false;
3336+
// objectstack#30035 — a re-read after a change merges the newest
3337+
// rows into what the feed already holds: a feed that holds the
3338+
// window (the 动态 tab read it) still does, so it never asks it again.
3339+
const peek = rereading ? held && answeredPeek : answeredPeek;
3340+
return held === peek ? prev : { ...prev, [threadId]: peek };
3341+
});
32973342
if (!res?.data?.length) return;
32983343
const mapped = activityRowsToFeedItems(res.data, t('detail.systemActor', { defaultValue: 'System' }));
32993344
if (!mapped.length) return;
@@ -3321,7 +3366,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
33213366
});
33223367

33233368
return () => { cancelled = true; };
3324-
}, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady, feedRetryNonce]);
3369+
// objectstack#30035 — `recordInvalidationNonce`: a change to this record
3370+
// reads its feed again (#2269, refresh data in place), as it reads the
3371+
// record again in the record-load effect above.
3372+
}, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady, feedRetryNonce, recordInvalidationNonce]);
33253373

33263374
// objectstack#29188 — the load-failure notice's Retry: read this record's
33273375
// FAILED feed reads again, and only those. The feed shows its loading state

‎ui/packages/app-shell/src/views/recordPagePayload.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,24 @@ export function recordPageRequestOf(plan: {
238238
};
239239
}
240240

241+
/** The record's streams a page asked when it opened (objectstack#30035): the feed's parts of its plan. */
242+
export interface RecordPageStreamsAsk {
243+
discussion?: boolean;
244+
activity?: number;
245+
timeline?: { discussion: boolean; activity: boolean; fieldChanges: boolean };
246+
}
247+
248+
/**
249+
* The request body for the feed's re-read after a change to the record
250+
* (objectstack#30035): the streams the open asked, as asked, and no other
251+
* part — no related list, verdict, projection or approval, which their own
252+
* readers read again on the same change. The route answers the record with
253+
* it, as its read gate.
254+
*/
255+
export function recordPageStreamsRequestOf(streams: RecordPageStreamsAsk): RecordPageRequest {
256+
return recordPageRequestOf({ expand: [], related: [], explain: [], fieldAccess: false, approvals: false, ...streams });
257+
}
258+
241259
/** A refused part, thrown as the read it replaces throws its refusal. */
242260
function refusal(error: RecordPagePartRefusal | undefined, fallback: string): Error {
243261
return Object.assign(new Error(error?.message ?? fallback), {

0 commit comments

Comments
 (0)