Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 29c19cc

Browse files
fix(app-shell): a record page reads its timeline, comments, user list and foot attachments when shown (objectui#13010 part 2) (#13021)
Part of #13010 Clause-②: no Part 2 of the card: Wanted item 4. Items 1 and 2 landed as part 1 (`46dbf32`). Items 3 and 5 are not in this PR, so the card stays open. ## What changes `RecordDetailView` used to send four reads beside the record read on every open: `sys_activity` (`top=200`, expand `actor_id`), the record's `sys_comment` thread, `sys_user` (`top=50`, the comment mention list), and, for an `enable.files` object on a page that places no `record:attachments` node, the host's foot-of-page Attachments panel's `sys_attachment` (`top=100`). On HotCRM's opportunity page none of the four is drawn at open. The timeline is a `record:activity` block in the Activity tab, there is no discussion block, and the Attachments panel is the host's fallback below the Details tab. ### P1: each read, its caller, and what the first paint draws from it | Read | Caller | What draws it | On first paint? | |---|---|---|---| | `sys_activity` `top=200` | `RecordDetailView` feed effect | DiscussionContext rows, read by `record:discussion` / `record:chatter` / `record:activity` | Only when one of those blocks is mounted at open | | `sys_comment` | the same effect | the same | the same | | `sys_user` `top=50` | `RecordDetailView` mention effect | the composer's mention suggestions in those same blocks; also read when a comment is posted, which needs a composer on screen | the same | | `sys_attachment` `top=100` | `RecordAttachmentsPanel` refresh | that panel | Inside a tab: only when its tab is active (Radix unmounts inactive tab content, so this was already the case). The host's foot-of-page fallback: mounted on every open, but below the record's content | Nothing in the header draws any of them. The header reads the record's own expanded fields. The `page:tabs` badge probes, including the Attachments tab's `sys_attachment` count probe, are separate reads in `containers.tsx`, untouched by this PR. ### The change, per read (the smaller correct change for each) - **`sys_activity` + `sys_comment` + `sys_user`: read when their block is shown.** This is "on show", not "after first paint". `useAfterFirstPaint` (objectui#12980 part 3) can be reached from the record page, but it marks the console's boot paint. On an in-app open (list to record), the measured case, it has already fired, so it would defer nothing. The view now judges whether a feed block is on screen from the tree it renders (with `enable.feeds: false` stripping the discussion nodes, as it already did for rendering) and from `?tab=`. A `page:tabs` strip mounts only its active item. The active item is chosen the way `PageTabsRenderer` chooses it: `?tab=` when it names an item, else the first item. Every case the view cannot read keeps the old eager read: a strip the URL does not drive (a strip nested in a tab item), a strip with a `visibleWhen` item, a strip with its own `defaultTab` and no `?tab=`, and blocks outside any strip (the synthesized page's discussion footer). Once a block has been on screen, the reads stay wanted for the view's life. A drawer swaps records without remounting, and the strip keeps its own selection while `?tab=` may not follow it. Until the reads go out, the feed reports the loading state it already had (`feedLoading`), which is what a block shows when it mounts. - **The foot-of-page Attachments panel: read once it scrolls into view.** It is always mounted but drawn below the record's content. So it gets an `IntersectionObserver` (`rootMargin: 200px`), the same gate the reference rail's reads use. Until then the panel is handed no data source. It then issues no read and stays in its own `loading` state, never the empty state. With no observer (SSR, a test environment), it reads at once as before. - `RecordAttachmentsPanel.tsx`, `record-activity.tsx` and `record-history.tsx` are unchanged. The first already reads on mount only. The other two own none of the four reads: their self-fetch runs only without a host DiscussionContext, or only in their own tab. No export, prop or type member is added. No cache layer. ### Wave table: harness `RecordDetailView.recordOpenWaves-13010.test.tsx`, HotCRM-shaped page (Details, Related, Activity with `record:activity`) over an `enable.files` object Before (base `4016370af3`): 14 requests, all in wave 1: ``` w1 GET approvals/requests w1 POST security/explain (update) w1 POST security/explain (delete) w1 GET data/os_13010_deal/rec-13010 w1 GET data/sys_user top=50 w1 GET data/sys_comment w1 GET data/sys_activity top=200 w1 GET data/sys_attachment top=100 w1 GET data/os_13010_{quote,line,task} top=1 (x3, badge probes) w1 GET data/os_13010_{quote,line,task} top=3 (x3, rail cards) ``` After (`9bb86845d2`): 10 requests, all in wave 1, and none of the four. Opening the Activity tab then reads `sys_activity`, `sys_comment` and `sys_user` once each. Scrolling the foot panel into view reads `sys_attachment` once. On part 1's page, which has no feed block and no `enable.files`, the open goes from 13 requests to 10. ## Pins (in the part-1 harness) - (a) A record whose default tab draws none of them issues none of the four reads at open. The foot panel is mounted and does not show rows. - (b) Opening the Activity tab issues `sys_activity` (`top=200`), `sys_comment` and `sys_user top=50` once each, after the open's requests, and draws the activity and comment rows. Scrolling the foot panel in issues `sys_attachment top=100` once and draws its row. - (c) CONTROLS. `?tab=activity` reads all three in wave 1 and draws the same rows. `?tab=attachments` reads the list in wave 1. The Attachments tab's `top=1` badge probe goes out in wave 1 whether that tab is active or not. The synthesized page (discussion footer) still reads the feed and the user list in wave 1. Part 1's three harness tests are unchanged and green. - `RecordDetailView.recordOpenRequests-12701.test.tsx` pinned "the activity read unchanged: 1" on a page with no feed block. That page now reads none, so the pin says 0 and points to the harness for the read-once half. ### Ablations: one per moved read, each through `ablation-replace.mjs` (anchor hit counted, blob changed, restore proven by blob hash equal to HEAD and an empty `git diff HEAD`). Pin (a) went red on all three - Feed gate removed (`if (!feedWanted) return;` deleted): (a) red with `w1 GET data/sys_comment` and `w1 GET data/sys_activity top=200`. The `sys_activity` and `sys_comment` reads share one effect, so one gate covers both. Blob `ae4c165df435` to `2ba42a0b1e2a`, then restored to `ae4c165df435`. - Mention gate removed: (a) red with `w1 GET data/sys_user top=50`. Blob to `05c7bf455fe1`, then restored. - Foot panel handed its data source eagerly: (a) red with `w1 GET data/sys_attachment top=100`. Blob to `df8241ecdebc`, then restored. The "before" table above was measured by putting the base blob `8e061749eb57` in place under an EXIT/INT/TERM trap. It was restored by `git checkout HEAD --` and proven by hash: `ae4c165df435`, the HEAD blob, with an empty diff. ## Gates (all on `9bb86845d2` unless named) - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; `--listFilesOnly` shows the test project reads the harness): exit 0. It ran on `453eb0720b`. The later commit touched one test assertion only. - vitest, `--maxWorkers=1`, one package at a time, under the shared lock: - The harness plus the 12701 file: 12/12. - Family files naming `RecordDetailView`, `RecordAttachmentsPanel`, `record-activity`, `record-history`, `recordOpenWaves-13010`, `authoredActivityThreading`, `comment`, `mention`: app-shell 70 files / 460 tests (1 failure, the 12701 pin above, since updated and green); plugin-detail 10 files / 135 tests; collaboration 4 files / 56 tests. - A further 126 app-shell / console test files that name `sys_comment`, `sys_activity`, `sys_attachment`, `mentionSuggestions` or `RecordDetailView`: 1120/1120. - **Declared to CI:** the full `packages/app-shell/` directory, too large to run locally inside the time cap. Also `packages/plugin-detail/` and `packages/collaboration/`, of which only the family files above ran. - `pnpm lint`, narrowed to the 3 changed source and test files: exit 0, 0 errors. `RecordDetailView.tsx` carries 102 warnings, against 104 on the base blob. Both test files have 0 warnings. Population: the root `eslint.config.js`, which every package's `eslint .` reads. File count: 3, from `--format json`. Invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on any untouched file. - Pre-tsc battery, each exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`. - `scripts/check-changeset-fixed.mjs`: exit 0. `scripts/check-changeset-presence.mjs`: exit 0 (1 changeset, `@object-ui/app-shell` patch). `check:new-line-citations`: 0 new. ## Acceptance notes - `sys_audit_log` (`top=50`, plus a `sys_user` `$in` name lookup) is still read at open when the object tracks history, before the History tab is opened. It is the same family as item 4 but not one of the four reads this part names. It was not in the measured HotCRM wave. Noted, not filed. Carrier: none. - "Shown" means mounted. A feed block inside a collapsed `page:accordion` panel, or on a strip this view cannot read, keeps the eager read. That is the conservative side: such a block can never sit in a loading state the view never ends. - Not browser-verified against a running stack. The harness drives the real page over the real adapter with a held transport. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4016370 commit 29c19cc

4 files changed

Lines changed: 406 additions & 17 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
A record page reads its timeline, comments, user list and foot-of-page attachments when they are shown, not on every open (objectui#13010, part 2).
6+
7+
`RecordDetailView` used to send four reads beside the record read on every open: the `sys_activity` timeline (`top=200`), the record's `sys_comment` thread, a `sys_user` list of 50 for comment mentions, and, for an object with `enable.files`, the host's own Attachments panel's `sys_attachment` list (`top=100`). They shared the record read's wave, although nothing on the open drew them on a page whose default tab is not the one holding them. HotCRM's opportunity page keeps `record:activity` in its Activity tab.
8+
9+
- **Timeline, comments and the mention list.** They are read when a `record:discussion`, `record:chatter` or `record:activity` block is on screen. That means at open when the page shows one: the synthesized page's discussion footer, a block outside any tab, or `?tab=` naming the tab that holds one. Otherwise they are read when the tab that holds one is opened. Until then the block shows its existing loading state. Once read, a record keeps its rows as before.
10+
- **The foot-of-page Attachments panel** is the panel the host appends to a page that places no `record:attachments` node. It reads its list once it scrolls into view. Until then it shows its loading state. A `record:attachments` node inside a tab already read only when its tab opened, and still does.
11+
- **Unchanged:** the tab strip's count badges, including the Attachments tab's `sys_attachment` count, still go out at open. What each panel shows once opened is unchanged.
12+
13+
Nothing is added to the package entry: no export, prop or type member.

‎packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ afterEach(() => {
269269
});
270270

271271
describe('a record open sends one approvals read and one record read (objectui#12701)', () => {
272-
it('counts the requests by URL: approvals 1, record 1, the activity read unchanged', async () => {
272+
it('counts the requests by URL: approvals 1, record 1, no activity read on a page that draws none', async () => {
273273
const { held } = await openRecordPage();
274274
await waitFor(() => expect(screen.getAllByText(RECORD.stage).length).toBeGreaterThan(0));
275275

@@ -282,8 +282,11 @@ describe('a record open sends one approvals read and one record read (objectui#1
282282
// The one read carries every relation the page's sections draw, and more.
283283
expect(expansionOf(recordReads[0])).toEqual(['account', 'created_by', 'owner']);
284284

285-
// Out of the card's scope, and not duplicated: the record's activity read.
286-
expect(held.filter(isActivityRead), 'activity reads').toHaveLength(1);
285+
// Out of the card's scope: the record's activity read. This page places no
286+
// block that draws the feed, so it is not read at all (objectui#13010 part
287+
// 2); that a page which does draw it reads it once is pinned beside the
288+
// record-open waves (`RecordDetailView.recordOpenWaves-13010.test.tsx`).
289+
expect(held.filter(isActivityRead), 'activity reads').toHaveLength(0);
287290
});
288291

289292
it('CONTROL: every approvals consumer still shows its state', async () => {

‎packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx‎

Lines changed: 229 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
*/
2626
import * as React from 'react';
2727
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
28-
import { render, cleanup, act } from '@testing-library/react';
28+
import { render, cleanup, act, screen, fireEvent } from '@testing-library/react';
2929
import { MemoryRouter } from 'react-router-dom';
3030

3131
vi.mock('@object-ui/auth', async (importOriginal) => ({
@@ -50,6 +50,8 @@ import { ObjectStackAdapter } from '@object-ui/data-objectstack';
5050
import { __clearRecordEditableCache } from '@object-ui/plugin-detail';
5151
import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals';
5252
import { RecordDetailView } from './RecordDetailView';
53+
// Registers `record:attachments`, which the package entry registers in the console.
54+
import './record-attachments-renderer';
5355

5456
const BASE = 'http://test.local';
5557
const OBJECT = 'os_13010_deal';
@@ -82,7 +84,6 @@ const child = (name: string, label: string, fk: string) => ({
8284
const quoteDef = child(QUOTE, 'Quote', 'deal');
8385
const lineDef = child(LINE, 'Line', 'deal');
8486
const taskDef = child(TASK, 'Task', 'related_deal');
85-
const ALL_OBJECTS = [objectDef, quoteDef, lineDef, taskDef];
8687

8788
const RECORD = { id: REC, name: 'Lattice renewal', stage: 'prospecting' };
8889

@@ -169,6 +170,20 @@ function json(body: unknown): Response {
169170
const childRows = (object: string, n: number) =>
170171
Array.from({ length: n }, (_, i) => ({ id: `${object}-${i}`, name: `${object} row ${i}`, status: 'open' }));
171172

173+
/** The rows the four panel reads answer (objectui#13010 part 2). */
174+
const FEED_ROWS: Record<string, Array<Record<string, unknown>>> = {
175+
sys_activity: [
176+
{ id: 'act-1', object_name: OBJECT, record_id: REC, type: 'updated', summary: 'Stage moved to proposal', actor_name: 'Ada', timestamp: '2026-01-02T00:00:00Z' },
177+
],
178+
sys_comment: [
179+
{ id: 'cmt-1', thread_id: `${OBJECT}:${REC}`, body: 'Ping the buyer on Friday', author_name: 'Ada', author_id: 'u1', created_at: '2026-01-03T00:00:00Z' },
180+
],
181+
sys_user: [{ id: 'u2', name: 'Grace', email: 'grace@test.local', image: null }],
182+
sys_attachment: [
183+
{ id: 'att-1', file_id: 'file-1', file_name: 'signed-contract.pdf', size: 2048, created_at: '2026-01-04T00:00:00Z' },
184+
],
185+
};
186+
172187
/** The `top` a data read asked for, or undefined. */
173188
const topOf = (h: Held): number | undefined => {
174189
const q = new URLSearchParams(h.path.slice(h.path.indexOf('?') + 1));
@@ -189,6 +204,10 @@ function answerFor(h: Held): unknown {
189204
const top = topOf(h) ?? total;
190205
return { success: true, data: { records: childRows(object, Math.min(top, total)), total } };
191206
}
207+
if (object && object in FEED_ROWS) {
208+
const rows = FEED_ROWS[object];
209+
return { success: true, data: { records: rows, total: rows.length } };
210+
}
192211
if (h.path.startsWith('/api/v1/data/')) return { success: true, data: { records: [], total: 0 } };
193212
return { success: true, data: [] };
194213
}
@@ -268,17 +287,29 @@ class VisibleObserver {
268287
disconnect() {}
269288
}
270289

271-
async function openRecordPage() {
290+
interface OpenOptions {
291+
/** The record page; HotCRM's shape above by default. */
292+
page?: unknown;
293+
/** The record's object definition; `objectDef` above by default. */
294+
object?: Record<string, unknown>;
295+
/** The route's query string, e.g. `?tab=activity`. */
296+
search?: string;
297+
/** The IntersectionObserver the page sees; every element on screen by default. */
298+
observer?: unknown;
299+
}
300+
301+
async function openRecordPage(options: OpenOptions = {}) {
272302
const { fetch, held, state } = heldTransport();
273303
vi.stubGlobal('fetch', fetch);
274-
vi.stubGlobal('IntersectionObserver', VisibleObserver);
304+
vi.stubGlobal('IntersectionObserver', options.observer ?? VisibleObserver);
275305
const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false });
276306
await ds.connect();
307+
const allObjects = [options.object ?? objectDef, quoteDef, lineDef, taskDef];
277308
(ds as unknown as { getObjectSchema: (n: string) => Promise<unknown> }).getObjectSchema = async (n: string) =>
278-
ALL_OBJECTS.find((o) => o.name === n) ?? null;
309+
allObjects.find((o) => o.name === n) ?? null;
279310

280-
const objects: unknown[] = ALL_OBJECTS;
281-
const pages = [RECORD_PAGE];
311+
const objects: unknown[] = allObjects;
312+
const pages = [options.page ?? RECORD_PAGE];
282313
const metadata = {
283314
objects, pages, loading: false, error: null,
284315
refresh: async () => {}, invalidate: () => {},
@@ -287,7 +318,7 @@ async function openRecordPage() {
287318
} as never;
288319
render(
289320
<React.StrictMode>
290-
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT}/record/${REC}`]}>
321+
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT}/record/${REC}${options.search ?? ''}`]}>
291322
<MetadataCtx.Provider value={metadata}>
292323
<RecordDetailView
293324
dataSource={ds as never}
@@ -301,7 +332,7 @@ async function openRecordPage() {
301332
</React.StrictMode>,
302333
);
303334
await runWaves(held, state);
304-
return { held };
335+
return { held, state };
305336
}
306337

307338
beforeEach(() => {
@@ -358,3 +389,192 @@ describe('the record page open waves (objectui#13010)', () => {
358389
expect(held.filter(isExplain).map((h) => JSON.parse(h.body).operation).sort(), waveTable(held)).toEqual(['delete', 'update']);
359390
});
360391
});
392+
393+
// ── objectui#13010 part 2: the panel reads wait for their panel ────────────
394+
395+
/** HotCRM's opportunity page with its Activity tab, and an Attachments tab. */
396+
const tabsPage = (items: unknown[]) => ({
397+
...RECORD_PAGE,
398+
regions: [
399+
{
400+
name: 'main',
401+
width: 'large',
402+
components: [{ type: 'page:tabs', id: 'deal_tabs', properties: { items } }],
403+
},
404+
RECORD_PAGE.regions[1],
405+
],
406+
});
407+
const [DETAILS_TAB, RELATED_TAB] = (RECORD_PAGE.regions[0].components[0] as { properties: { items: unknown[] } }).properties.items;
408+
const ACTIVITY_TAB = { value: 'activity', label: 'Activity', children: [{ type: 'record:activity', id: 'deal_activity' }] };
409+
const ATTACHMENTS_TAB = { value: 'attachments', label: 'Attachments', children: [{ type: 'record:attachments', id: 'deal_files' }] };
410+
/** HotCRM's own shape: Details first, the timeline in the Activity tab, and no Attachments node. */
411+
const ACTIVITY_PAGE = tabsPage([DETAILS_TAB, RELATED_TAB, ACTIVITY_TAB]);
412+
const FILES_TAB_PAGE = tabsPage([DETAILS_TAB, RELATED_TAB, ATTACHMENTS_TAB, ACTIVITY_TAB]);
413+
/** HotCRM's opportunity declares `enable.files`, so a page without the node gets the host's own panel. */
414+
const filesObject = { ...objectDef, enable: { files: true } };
415+
416+
const isPanelRead = (object: string) => (h: Held) => h.method === 'GET' && objectOf(h) === object;
417+
const isActivityRead = isPanelRead('sys_activity');
418+
const isCommentRead = isPanelRead('sys_comment');
419+
const isUserListRead = (h: Held) => isPanelRead('sys_user')(h) && topOf(h) === 50;
420+
const isAttachmentListRead = (h: Held) => isPanelRead('sys_attachment')(h) && topOf(h) === 100;
421+
/** The `record:activity` block's node, as `SchemaRenderer` marks it in the DOM. */
422+
const ACTIVITY_MOUNT = '[data-obj-id="deal_activity"]';
423+
const FOUR_READS = [isActivityRead, isCommentRead, isUserListRead, isAttachmentListRead];
424+
425+
/**
426+
* Every element is on screen except the host's foot-of-page Attachments panel,
427+
* which is below the record's content until `scrollFallbackIntoView` says it
428+
* scrolled in.
429+
*/
430+
const offscreen: Array<{ cb: (entries: Array<{ isIntersecting: boolean }>) => void; live: boolean }> = [];
431+
class FoldObserver {
432+
private readonly entries: Array<{ cb: FoldObserver['cb']; live: boolean }> = [];
433+
constructor(private readonly cb: (entries: Array<{ isIntersecting: boolean }>) => void) {}
434+
observe(el: Element) {
435+
if (el.hasAttribute('data-record-attachments-fallback')) {
436+
const entry = { cb: this.cb, live: true };
437+
this.entries.push(entry);
438+
offscreen.push(entry);
439+
return;
440+
}
441+
queueMicrotask(() => this.cb([{ isIntersecting: true }]));
442+
}
443+
unobserve() {}
444+
disconnect() {
445+
for (const entry of this.entries) entry.live = false;
446+
}
447+
}
448+
async function scrollFallbackIntoView() {
449+
await act(async () => {
450+
for (const entry of offscreen) if (entry.live) entry.cb([{ isIntersecting: true }]);
451+
});
452+
}
453+
454+
/** Open a tab the way a user does: Radix activates a trigger on mouse down. */
455+
async function openTab(name: string) {
456+
const trigger = screen.getByRole('tab', { name: new RegExp(`^${name}`) });
457+
await act(async () => {
458+
fireEvent.mouseDown(trigger, { button: 0, ctrlKey: false });
459+
});
460+
}
461+
462+
describe('the timeline, comments, attachments and user list wait for their panel (objectui#13010 part 2)', () => {
463+
beforeEach(() => {
464+
offscreen.length = 0;
465+
});
466+
467+
// Wave table measured on this file, ACTIVITY_PAGE over `filesObject`. Before
468+
// this part: `sys_user top=50`, `sys_comment`, `sys_activity top=200` and the
469+
// foot panel's `sys_attachment top=100` all in wave 1 beside the record read.
470+
// After it: none of the four until the tab opens or the panel scrolls in.
471+
it('a record whose default tab draws none of them issues none of the four reads at open', async () => {
472+
const { held } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver });
473+
const table = waveTable(held);
474+
expect(held.filter(isRecordRead).map((h) => h.wave), table).toEqual([1]);
475+
for (const isRead of FOUR_READS) expect(held.filter(isRead), table).toEqual([]);
476+
// The foot panel is mounted and shows its loading state, not "no attachments".
477+
const fallback = document.querySelector('[data-record-attachments-fallback]');
478+
expect(fallback, table).not.toBeNull();
479+
expect(fallback?.textContent ?? '', table).not.toContain('signed-contract.pdf');
480+
});
481+
482+
it('opening the Activity tab reads the timeline, the comments and the user list once each, and draws their rows', async () => {
483+
const { held, state } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver });
484+
// Everything the open asked for is answered; the tab's reads come after.
485+
const openedWith = held.length;
486+
await openTab('Activity');
487+
await runWaves(held, state);
488+
const table = waveTable(held);
489+
for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) {
490+
const reads = held.filter(isRead);
491+
expect(reads, table).toHaveLength(1);
492+
expect(reads[0].seq, table).toBeGreaterThanOrEqual(openedWith);
493+
}
494+
expect(held.filter(isActivityRead)[0].path, table).toContain('top=200');
495+
expect(screen.getByText('Stage moved to proposal')).toBeTruthy();
496+
expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy();
497+
// The attachments list is not the Activity tab's: still unread.
498+
expect(held.filter(isAttachmentListRead), table).toEqual([]);
499+
});
500+
501+
// Drift pin: "not read" holds only because the block is not MOUNTED. A tab
502+
// strip that began mounting a non-first tab unprompted would draw the block
503+
// over a feed nobody reads; this turns red on that, not just on a read.
504+
it('a page whose feed tab is not first, opened with no ?tab=, neither reads the feed nor mounts its block', async () => {
505+
const { held } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver });
506+
const table = waveTable(held);
507+
for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toEqual([]);
508+
expect(document.querySelector(ACTIVITY_MOUNT), table).toBeNull();
509+
// Nor any feed panel at all: a mounted one over an unread feed shows its loading state.
510+
expect(screen.queryByTestId('activity-loading'), table).toBeNull();
511+
});
512+
513+
// An authored `properties.defaultTab` beats `?tab=` in the renderer (the
514+
// `properties` hoist runs after `withPageTabsUrlSync` writes `?tab=`), so the
515+
// view may not judge such a strip by `?tab=`: it reads the feed at open.
516+
it('a strip whose properties.defaultTab names the Activity tab still reads the feed when ?tab= names another tab', async () => {
517+
const page = {
518+
...ACTIVITY_PAGE,
519+
regions: [
520+
{ name: 'main', width: 'large', components: [{ type: 'page:tabs', id: 'deal_tabs', properties: { items: [DETAILS_TAB, ACTIVITY_TAB], defaultTab: 'activity' } }] },
521+
RECORD_PAGE.regions[1],
522+
],
523+
};
524+
const { held } = await openRecordPage({ page, object: filesObject, observer: FoldObserver, search: '?tab=details' });
525+
const table = waveTable(held);
526+
for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toHaveLength(1);
527+
expect(document.querySelector(ACTIVITY_MOUNT), table).not.toBeNull();
528+
expect(screen.getByText('Stage moved to proposal')).toBeTruthy();
529+
expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy();
530+
});
531+
532+
it('the foot-of-page Attachments panel reads once it scrolls into view, and draws its rows', async () => {
533+
const { held, state } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver });
534+
expect(held.filter(isAttachmentListRead), waveTable(held)).toEqual([]);
535+
await scrollFallbackIntoView();
536+
await runWaves(held, state);
537+
const table = waveTable(held);
538+
expect(held.filter(isAttachmentListRead), table).toHaveLength(1);
539+
expect(screen.getByText('signed-contract.pdf')).toBeTruthy();
540+
});
541+
542+
it('CONTROL: a record opened on its Activity tab reads all three at open, and draws the same rows', async () => {
543+
const { held } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver, search: '?tab=activity' });
544+
const table = waveTable(held);
545+
// The mount marker the drift pin below reads is real: present when the tab is.
546+
expect(document.querySelector(ACTIVITY_MOUNT), table).not.toBeNull();
547+
for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) {
548+
expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]);
549+
}
550+
expect(screen.getByText('Stage moved to proposal')).toBeTruthy();
551+
expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy();
552+
});
553+
554+
it('CONTROL: a record opened on its Attachments tab reads the list at open; the tab badge probe goes out either way', async () => {
555+
const closed = await openRecordPage({ page: FILES_TAB_PAGE, object: filesObject, observer: FoldObserver });
556+
const closedTable = waveTable(closed.held);
557+
expect(closed.held.filter(isAttachmentListRead), closedTable).toEqual([]);
558+
const closedProbes = closed.held.filter((h) => isPanelRead('sys_attachment')(h) && topOf(h) === 1);
559+
expect(closedProbes.map((h) => h.wave), closedTable).toEqual([1]);
560+
561+
cleanup();
562+
RelatedCountStore._reset();
563+
__clearRecordEditableCache();
564+
__resetRecordApprovalsStore();
565+
566+
const open = await openRecordPage({ page: FILES_TAB_PAGE, object: filesObject, observer: FoldObserver, search: '?tab=attachments' });
567+
const openTable = waveTable(open.held);
568+
expect(open.held.filter(isAttachmentListRead).map((h) => h.wave), openTable).toEqual([1]);
569+
expect(open.held.filter((h) => isPanelRead('sys_attachment')(h) && topOf(h) === 1).map((h) => h.wave), openTable).toEqual([1]);
570+
expect(screen.getByText('signed-contract.pdf')).toBeTruthy();
571+
});
572+
573+
it('CONTROL: the synthesized page draws its discussion footer at open, so it still reads the feed and the user list at open', async () => {
574+
const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver });
575+
const table = waveTable(held);
576+
for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) {
577+
expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]);
578+
}
579+
});
580+
});

0 commit comments

Comments
 (0)