Skip to content

Commit 7990fb0

Browse files
fix(plugin-detail): remember "show empty fields" per user and object (#26560)
Fixes #25883 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录详情页每个分区的「显示 N 个空字段」不再是各分区各自、刷新即忘的开关,而是当前用户对该对象的一个选择:点一次,该对象详情页的所有分区都显示空字段,换一条同对象记录、刷新、换设备都保持;其它对象不受影响。存在 `sys_user_preference` 的 `ui.record.OBJECT` 行(值 `showEmptyFields: true/false`),与列表页的 `ui.list.OBJECT` 同机制、同一次偏好读取。 - 为什么改:#25883 第 2 项。HotCRM 浏览巡检中,商机页三个分区要点三次,刷新或打开下一条商机又全部折叠。第 1 项(铅笔焦点显示)已在 #26325 落地。 - 风险与回滚:没有偏好服务(未登录、宿主未挂桥接)时行为与原来完全一致(各分区内存态)。作者写的 `hideEmpty: true` 全空分区不会因该选择被显示出来(有测试)。控制台启动的那一次偏好读取多带一个 `ui.record.` 前缀分支,请求数仍是一次。回滚 = revert 本 PR,已写入的 `ui.record.*` 行无人读取、无害。 - 先看:`ui/packages/plugin-detail/src/DetailSection.tsx` 的 `rememberedShowEmpty`,以及 `ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx`。plugin-detail 经 `@object-ui/react` 的新 context 读取,不依赖 app-shell,未新增包依赖。 - 两分钟验证:登录控制台打开一条有空字段的商机,点任一分区的「显示 N 个空字段」,其余分区同时展开;刷新、打开另一条商机仍展开;打开客户记录仍折叠。 - 需要你做的:无。不涉及受管面,CI 绿后由席位按常规合并队列落地。 ## What changed The record page's "Show N empty fields" row is now one remembered choice per user and object, instead of a per-section toggle that reset on unmount. **Storage key: `ui.record.OBJECT`** (one `sys_user_preference` row per object, for example `ui.record.opportunity`), value `{ "showEmptyFields": true }` or `false`. It is a new key family beside the list page's `ui.list.OBJECT` (objectui#12418), and it uses the same mechanism: `createObjectStackUserPreferenceFamily`, attached by the console's `UserStateBridge`, loaded by prefix in the console's one shared preference read, and each object's row upserted on its own. I chose a separate family over a new field inside `ui.list.OBJECT` because that row is documented and sanitized as list state; record-page state there would be dropped by `sanitizeObjectListPreference` and would blur what the row means. `data-objectstack`'s shared read already takes several prefixes, so `userState.ts` is unchanged. - `ui/packages/react/src/context/RecordPagePreferencesContext.tsx` (new): `RecordPagePreferencesProvider`, `useRecordPagePreferences`, and the `RecordPagePreference` / `RecordPagePreferencesStore` types. This is the seam to the record page. The sections reach the page through `ComponentRegistry`, so no host prop reaches them. A context in `@object-ui/react` does reach them, as `HostNavigationContext` and `HostMapStyleContext` already do for their renderers. plugin-detail and app-shell both already depend on `@object-ui/react`, so **no package dependency was added** and plugin-detail does not import app-shell. - `ui/packages/plugin-detail/src/DetailSection.tsx`: under a store and with an `objectName`, the toggle reads and writes the store's `showEmptyFields` for that object, so every section of the object follows one choice. With no store or no object name, it keeps its own `useState`, exactly as before. The authored all-empty hide (`hideEmpty: true`) now applies regardless of the choice. Before, the in-memory toggle could never be on for such a section because it never drew the toggle. An object-wide choice can reach it, so the hide is now applied explicitly. - `ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx` (new): loads the family's rows on attach (a user switch drops the previous user's choices), keeps a choice made before the read answers over the stored row, and writes one row per click. With no family attached it hands the page no store (`null`), so the old in-memory behaviour applies. There is no localStorage copy. - `ui/packages/app-shell/src/context/UserStateAdapters.tsx`: a `recordState` family slot with `useRecordStateFamily` and `useAttachRecordStateFamily`, mirroring `listState`. - `ui/packages/app-shell/src/console/ConsoleShell.tsx`: the bridge builds and attaches the `ui.record.` family next to `ui.list.`, and `RecordPreferencesProvider` is mounted inside `ListPreferencesProvider`. Most of this file's diff is a two-space re-indent of the nested children. - `.changeset/25883-empty-fields-remembered.md`: `@object-ui/react`, `@object-ui/plugin-detail`, `@object-ui/app-shell` patch. Not changed: locale packs (the existing `detail.showEmptyFields` / `detail.hideEmptyFields` strings are reused), `HeaderHighlight.tsx`, `RelatedList.tsx`, `ListPreferencesProvider.tsx`, and `data-objectstack`. No registry component contract changed, so no SDUI manifest regeneration was needed. ## Tests - New `ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx` (7 tests) pins these cases: toggle, remount, still shown; the object's other sections follow; another object is unaffected; the store receives `showEmptyFields: true` then `false`; a stored choice opens shown; no store means per-section toggles that are gone on unmount (the old behaviour); no `objectName` means the section's own toggle; a remembered choice does not undo `hideEmpty: true`. - New `ui/packages/app-shell/src/context/__tests__/RecordPreferencesProvider-25883.test.tsx` (5 tests), with the real `DetailSection` under the real provider and a fake family. Toggle, remount with the reloaded row, still shown. The other sections follow, another object is unaffected, and `ui.record.opportunity` is written. With no family, the hook answers `null` and each section keeps its own toggle. The provider reads only `ui.record.` rows, sanitized. A choice made before the read answers is kept. A user switch drops the previous user's choices. - Extended `ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx` (real `ConsoleShell` and `ConnectedShell`, fake preference server): one boot is still ONE `sys_user_preference` GET, and it now asks for both prefixes. The record-page consumer receives its own `ui.record.account` row, writes only that row, and a user switch reads the new user's row. - **Red first**, at `1b034058` (the seam and the plugin-detail pin committed, the DetailSection change absent): `Tests 4 failed | 2 passed (6)`. The four failures are the remount, all-sections, store-write and stored-choice cases. The two that pass are the no-store and no-object-name cases, which are the old behaviour by design. This leg ran without the verify lock after three 540 s queue timeouts (one file, `--maxWorkers=1`), with the backend packages' built `dist/` copied from the same base. - **Green**, at head `2646abdc`, under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0): - closure build `pnpm --filter '@object-ui/plugin-detail^...' --filter '@object-ui/app-shell^...' build`: exit 0; - `pnpm exec vitest run --maxWorkers=2 packages/plugin-detail` (cwd `ui/`): `Test Files 310 passed | 1 skipped (311)`, `Tests 2876 passed | 18 skipped (2894)`. 311 equals the test files on disk; - the 25 app-shell test files that import or read `ConsoleShell`, `UserStateAdapters` or the new provider (not the full app-shell suite): `Test Files 25 passed (25)`, `Tests 201 passed (201)`; - `pnpm --filter @object-ui/plugin-detail run type-check`, `pnpm --filter @object-ui/react run type-check` and `pnpm --filter @object-ui/app-shell run type-check` (each runs `tsc --noEmit` and `tsc -p tsconfig.test.json`): all exit 0. - Gates, at `2646abdc`, from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived. `--ran` reconciliation: 33 run, 1 NOT-MEASURED, 0 UNRUN. - 32 exited 0, including `check-sdui-manifest.mjs`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:undeclared-dep-imports` and the changeset gates. - `pnpm --dir ui exec vitest run scripts/` exited 1: 5094 passed and 3 failed, all three on the 15 s test timeout on a contended box. Those three files (`check-hand-rolled-comment-mask`, `check-vi-mock-inherit`, `js-comment-mask-returns-interpolation-9424`) were then re-run on their own: `Tests 113 passed (113)`. - `pnpm check:type-check-debt` is NOT-MEASURED. Its re-measure needs a closure build on the shared verify lock, which timed out twice in the queue. CI `Lint & Repo Gates` carries it. ## Acceptance notes - The tab-level fold (`EmptySectionsFold`, shown when every section of a body is empty) keeps its own in-memory reveal. It was outside this claim's file surface. Under a remembered "show", revealing it draws the sections with their empty fields already shown, and their "Hide empty fields" rows clear the choice. Whether that fold should read the same choice is a follow-up question, not a defect. - Until the boot's preference read answers, a section draws folded. The read is sent at boot beside the metadata reads, so it has normally answered before a record page renders. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a14f3e6 commit 7990fb0

10 files changed

Lines changed: 770 additions & 50 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
fix(plugin-detail): the record page remembers "Show empty fields" per user and object
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
On a record page, each section's "Show N empty fields" row used to open that one section only, and the choice was gone after a reload or on the next record. A reader who wanted the whole record clicked once per section, every time.
13+
14+
What changes:
15+
16+
- In the Console, the choice is now the signed-in user's, per object. One click shows the empty fields in every section of that object's record page. The choice holds on the next record of the same object, after a reload and on another device. Other objects keep their own choice.
17+
- It is stored in a `sys_user_preference` row per object, key `ui.record.<object>`, with the value `{ "showEmptyFields": true }` or `false`. The row sits beside the list page's `ui.list.<object>` rows and loads in the same single preference read.
18+
- `@object-ui/react` adds `RecordPagePreferencesProvider`, `useRecordPagePreferences` and the `RecordPagePreference` and `RecordPagePreferencesStore` types. A host uses them to hand its store to the record page's sections.
19+
- With no store (no signed-in user, or a host that provides none), each section keeps the toggle in its own state, as before.
20+
21+
What to do: nothing. Nothing a view or object declares changes.

‎ui/packages/app-shell/src/console/ConsoleShell.tsx‎

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,17 @@ import { FlowPaletteRecentsProvider } from '../context/FlowPaletteRecentsProvide
3939
import {
4040
UserStateAdaptersProvider,
4141
useAttachListStateFamily,
42+
useAttachRecordStateFamily,
4243
useAttachUserStateAdapters,
4344
} from '../context/UserStateAdapters.js';
4445
import {
4546
LIST_PREFERENCE_PREFIX,
4647
ListPreferencesProvider,
4748
} from '../context/ListPreferencesProvider.js';
49+
import {
50+
RECORD_PREFERENCE_PREFIX,
51+
RecordPreferencesProvider,
52+
} from '../context/RecordPreferencesProvider.js';
4853
import { ThemeProvider } from '../chrome/ThemeProvider.js';
4954
import { LoadingScreen } from '../chrome/LoadingScreen.js';
5055
import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js';
@@ -194,36 +199,38 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) {
194199
<RecentItemsProvider>
195200
<FlowPaletteRecentsProvider>
196201
<ListPreferencesProvider>
197-
{/* objectui#4467 — the impersonation indicator. Above the
198-
routes, so it is chrome for EVERY console page (home has
199-
its own layout and would otherwise carry no indicator),
200-
and in flow rather than overlaid, so it never covers the
201-
header it warns about. Renders null on every ordinary
202-
session. */}
203-
<ImpersonationBanner />
204-
{/* objectui#9954 — the environment admin's read-rate report.
205-
Beside the impersonation indicator for the same reason it
206-
is here: chrome for EVERY console page, including `/home`,
207-
which has its own layout and would otherwise carry no
208-
report. Renders null unless the control plane's verdict is
209-
`anomalous`, so an ordinary session and an unmeasured
210-
environment both see nothing — and a non-admin session
211-
never even issues the request, nor does any session on a
212-
runtime whose config does not serve `storageUsage`
213-
(objectui#11002). */}
214-
<ReadRateBanner />
215-
{/* objectui#10439 — the environment admin's storage-capacity
216-
banner, beside the read-rate report and behind the same
217-
two gates (the admin, and the runtime serving
218-
`storageUsage`); the two read one `/usage/storage` response.
219-
Renders null unless the runtime's own verdict says `warn`
220-
(80% and up) or `blocked` (uploads and imports refused). */}
221-
<StorageUsageBanner />
222-
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
223-
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
224-
<RemediationOverlay />
225-
<NotificationSnackbar />
226-
<NotificationAlerts />
202+
<RecordPreferencesProvider>
203+
{/* objectui#4467 — the impersonation indicator. Above the
204+
routes, so it is chrome for EVERY console page (home has
205+
its own layout and would otherwise carry no indicator),
206+
and in flow rather than overlaid, so it never covers the
207+
header it warns about. Renders null on every ordinary
208+
session. */}
209+
<ImpersonationBanner />
210+
{/* objectui#9954 — the environment admin's read-rate report.
211+
Beside the impersonation indicator for the same reason it
212+
is here: chrome for EVERY console page, including `/home`,
213+
which has its own layout and would otherwise carry no
214+
report. Renders null unless the control plane's verdict is
215+
`anomalous`, so an ordinary session and an unmeasured
216+
environment both see nothing — and a non-admin session
217+
never even issues the request, nor does any session on a
218+
runtime whose config does not serve `storageUsage`
219+
(objectui#11002). */}
220+
<ReadRateBanner />
221+
{/* objectui#10439 — the environment admin's storage-capacity
222+
banner, beside the read-rate report and behind the same
223+
two gates (the admin, and the runtime serving
224+
`storageUsage`); the two read one `/usage/storage` response.
225+
Renders null unless the runtime's own verdict says `warn`
226+
(80% and up) or `blocked` (uploads and imports refused). */}
227+
<StorageUsageBanner />
228+
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
229+
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
230+
<RemediationOverlay />
231+
<NotificationSnackbar />
232+
<NotificationAlerts />
233+
</RecordPreferencesProvider>
227234
</ListPreferencesProvider>
228235
</FlowPaletteRecentsProvider>
229236
</RecentItemsProvider>
@@ -350,13 +357,15 @@ function UserStateBridge() {
350357
const dataSource = useAdapter();
351358
const attach = useAttachUserStateAdapters();
352359
const attachListState = useAttachListStateFamily();
360+
const attachRecordState = useAttachRecordStateFamily();
353361

354362
useEffect(() => {
355363
if (!user?.id || !dataSource) {
356364
attach('favorites', null);
357365
attach('recent', null);
358366
attach('flowPaletteRecents', null);
359367
attachListState(null);
368+
attachRecordState(null);
360369
return;
361370
}
362371
const favorites = createObjectStackUserStateAdapter({
@@ -384,14 +393,23 @@ function UserStateBridge() {
384393
attach('favorites', favorites);
385394
attach('recent', recent);
386395
attach('flowPaletteRecents', flowPaletteRecents);
396+
// objectstack#25883 — every object's record-page state, `ui.record.<object>`
397+
// ("show empty fields"): a second key family in the same one read.
398+
const recordState = createObjectStackUserPreferenceFamily({
399+
dataSource,
400+
userId: user.id,
401+
prefix: RECORD_PREFERENCE_PREFIX,
402+
});
387403
attachListState(listState);
404+
attachRecordState(recordState);
388405
return () => {
389406
attach('favorites', null);
390407
attach('recent', null);
391408
attach('flowPaletteRecents', null);
392409
attachListState(null);
410+
attachRecordState(null);
393411
};
394-
}, [user?.id, dataSource, attach, attachListState]);
412+
}, [user?.id, dataSource, attach, attachListState, attachRecordState]);
395413

396414
return null;
397415
}

‎ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
* stays ONE read, and the read asks for the three keys and the `ui.list.`
2222
* prefix.
2323
*
24+
* objectstack#25883 adds a fifth: every object's record-page state
25+
* (`ui.record.<object>`, the "show empty fields" choice), a second key family
26+
* in the same read. The budget stays ONE read, asking for the three keys and
27+
* both prefixes.
28+
*
2429
* ## The tree
2530
*
2631
* The real `ConsoleShell` providers and the real `ConnectedShell`: its
@@ -34,7 +39,7 @@
3439
* ## The pins
3540
*
3641
* 1. One boot puts ONE `sys_user_preference` GET on the wire, and it asks
37-
* for the three keys and the list-state prefix.
42+
* for the three keys, the list-state prefix and the record-state prefix.
3843
* 2. Every consumer receives its own row from that one answer, the list
3944
* state of an object included.
4045
* 3. Each adapter still writes its own row, and only its own; the list state
@@ -87,6 +92,7 @@ import { useFavorites } from '../../context/FavoritesProvider';
8792
import { useRecentItems } from '../../context/RecentItemsProvider';
8893
import { useFlowPaletteRecents } from '../../context/FlowPaletteRecentsProvider';
8994
import { useObjectListPreferences, type ObjectListPreference } from '../../context/ListPreferencesProvider';
95+
import { useRecordPagePreferences } from '@object-ui/react';
9096
import { createPreferenceFramework, preferenceRowId as rowId, type PreferenceFramework } from './preferenceFramework';
9197

9298
const FAVORITES_KEY = 'ui.favorites';
@@ -96,6 +102,10 @@ const BRIDGE_KEYS = [FAVORITES_KEY, RECENT_KEY, FLOW_KEY];
96102
/** objectui#12418 — the list-state family, and one object's row of it. */
97103
const LIST_PREFIX = 'ui.list.';
98104
const LIST_KEY = 'ui.list.account';
105+
/** objectstack#25883 — the record-state family, and one object's row of it. */
106+
const RECORD_PREFIX = 'ui.record.';
107+
const RECORD_KEY = 'ui.record.account';
108+
const BOTH_PREFIXES = [LIST_PREFIX, RECORD_PREFIX].sort();
99109

100110
/** Each user's stored rows, one distinct payload per key, so a starved consumer shows up. */
101111
const STORE: Record<string, Record<string, unknown>> = {
@@ -104,12 +114,14 @@ const STORE: Record<string, Record<string, unknown>> = {
104114
[RECENT_KEY]: [{ id: 'object:lead', type: 'object', name: 'lead', href: '/lead', visitedAt: '2026-10-01T00:00:00.000Z' }],
105115
[FLOW_KEY]: ['flow_step_alpha'],
106116
[LIST_KEY]: { viewOrder: ['account.mine', 'account.all'] },
117+
[RECORD_KEY]: { showEmptyFields: true },
107118
},
108119
u2: {
109120
[FAVORITES_KEY]: [{ id: 'object:invoice', label: 'Invoice', href: '/invoice', type: 'object' }],
110121
[RECENT_KEY]: [{ id: 'object:quote', type: 'object', name: 'quote', href: '/quote', visitedAt: '2026-10-02T00:00:00.000Z' }],
111122
[FLOW_KEY]: ['flow_step_omega'],
112123
[LIST_KEY]: { viewOrder: ['account.all', 'account.closed'] },
124+
[RECORD_KEY]: { showEmptyFields: false },
113125
},
114126
};
115127

@@ -125,21 +137,24 @@ interface Seen {
125137
flow: string[];
126138
/** The `account` list state's tab order (objectui#12418). */
127139
listOrder: string[];
140+
/** The `account` record page's "show empty fields" choice (objectstack#25883). */
141+
recordShowEmpty: boolean | undefined;
128142
}
129143

130144
/**
131145
* Module-scope holders, written from an effect and never from the render phase
132146
* — the same shape `MetadataProvider.requestBudget.test.tsx` uses. Not props,
133147
* because writing through a prop is what the react-compiler rule refuses.
134148
*/
135-
const EMPTY_SEEN: Seen = { favorites: [], recent: [], flow: [], listOrder: [] };
149+
const EMPTY_SEEN: Seen = { favorites: [], recent: [], flow: [], listOrder: [], recordShowEmpty: undefined };
136150
const latest: { current: Seen } = { current: EMPTY_SEEN };
137151
const actions: {
138152
current: {
139153
addFavorite?: ReturnType<typeof useFavorites>['addFavorite'];
140154
addRecentItem?: ReturnType<typeof useRecentItems>['addRecentItem'];
141155
recordRecent?: ReturnType<typeof useFlowPaletteRecents>['recordRecent'];
142156
patchListView?: ReturnType<typeof useObjectListPreferences>['patchView'];
157+
updateRecordPage?: NonNullable<ReturnType<typeof useRecordPagePreferences>>['update'];
143158
};
144159
} = { current: {} };
145160

@@ -151,15 +166,19 @@ function Consumers() {
151166
const listPreference: ObjectListPreference = list.preference;
152167
const listOrderKey = JSON.stringify(listPreference.viewOrder ?? []);
153168
const patchListView = list.patchView;
169+
const recordPage = useRecordPagePreferences();
170+
const recordShowEmpty = recordPage?.get('account').showEmptyFields;
171+
const updateRecordPage = recordPage?.update;
154172
useEffect(() => {
155173
latest.current = {
156174
favorites: favorites.map((f) => f.id),
157175
recent: recentItems.map((r) => r.id),
158176
flow: [...recents],
159177
listOrder: JSON.parse(listOrderKey),
178+
recordShowEmpty,
160179
};
161-
actions.current = { addFavorite, addRecentItem, recordRecent, patchListView };
162-
}, [favorites, recentItems, recents, listOrderKey, addFavorite, addRecentItem, recordRecent, patchListView]);
180+
actions.current = { addFavorite, addRecentItem, recordRecent, patchListView, updateRecordPage };
181+
}, [favorites, recentItems, recents, listOrderKey, recordShowEmpty, addFavorite, addRecentItem, recordRecent, patchListView, updateRecordPage]);
163182
return <main data-testid="content">content</main>;
164183
}
165184

@@ -186,11 +205,11 @@ async function settle(ms = 150) {
186205

187206
const U1_SEEN: Seen = {
188207
favorites: ['object:contact'], recent: ['object:lead'], flow: ['flow_step_alpha'],
189-
listOrder: ['account.mine', 'account.all'],
208+
listOrder: ['account.mine', 'account.all'], recordShowEmpty: true,
190209
};
191210
const U2_SEEN: Seen = {
192211
favorites: ['object:invoice'], recent: ['object:quote'], flow: ['flow_step_omega'],
193-
listOrder: ['account.all', 'account.closed'],
212+
listOrder: ['account.all', 'account.closed'], recordShowEmpty: false,
194213
};
195214

196215
describe('console sys_user_preference request budget (objectui#12187)', () => {
@@ -207,16 +226,17 @@ describe('console sys_user_preference request budget (objectui#12187)', () => {
207226
vi.unstubAllGlobals();
208227
});
209228

210-
it('one boot puts one sys_user_preference read on the wire, asking for the three keys and the list-state prefix', async () => {
229+
it('one boot puts one sys_user_preference read on the wire, asking for the three keys and both family prefixes', async () => {
211230
boot();
212231
await waitFor(() => expect(wire().gets.length).toBeGreaterThan(0));
213232
await settle();
214233

215234
expect(wire().gets).toHaveLength(1);
216235
expect(wire().gets[0].user).toBe('u1');
217236
expect([...wire().gets[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort());
218-
// objectui#12418 — every object's list state rides the same read.
219-
expect(wire().gets[0].prefixes).toEqual([LIST_PREFIX]);
237+
// objectui#12418 / objectstack#25883 — every object's list state and
238+
// record-page state ride the same read.
239+
expect([...wire().gets[0].prefixes].sort()).toEqual(BOTH_PREFIXES);
220240
});
221241

222242
it('every consumer receives its own row from that one answer', async () => {
@@ -238,17 +258,19 @@ describe('console sys_user_preference request budget (objectui#12187)', () => {
238258
actions.current.addRecentItem?.({ id: 'object:deal', type: 'object', name: 'deal', href: '/deal' });
239259
actions.current.recordRecent?.('flow_step_beta');
240260
actions.current.patchListView?.('account.all', { sort: [{ field: 'name', order: 'desc' }] });
261+
actions.current.updateRecordPage?.('account', { showEmptyFields: false });
241262
});
242263

243-
// Each provider debounces its write (500 ms); all four land, once each.
244-
await waitFor(() => expect(wire().writes).toHaveLength(4), { timeout: 3000 });
264+
// Each provider debounces its write (500 ms; the record page writes on the
265+
// click); all five land, once each.
266+
await waitFor(() => expect(wire().writes).toHaveLength(5), { timeout: 3000 });
245267
await settle();
246-
expect(wire().writes).toHaveLength(4);
268+
expect(wire().writes).toHaveLength(5);
247269

248270
const byId = new Map(wire().writes.map((w) => [w.id, w]));
249271
// An update of the row its own load found — no insert, no other row.
250272
expect(wire().writes.every((w) => w.method === 'PATCH' || w.method === 'PUT')).toBe(true);
251-
expect([...byId.keys()].sort()).toEqual([...BRIDGE_KEYS, LIST_KEY].map((k) => rowId('u1', k)).sort());
273+
expect([...byId.keys()].sort()).toEqual([...BRIDGE_KEYS, LIST_KEY, RECORD_KEY].map((k) => rowId('u1', k)).sort());
252274
expect((byId.get(rowId('u1', FAVORITES_KEY))?.value as { id: string }[]).map((f) => f.id)).toContain(
253275
'object:account',
254276
);
@@ -259,6 +281,7 @@ describe('console sys_user_preference request budget (objectui#12187)', () => {
259281
viewOrder: ['account.mine', 'account.all'],
260282
perView: { 'account.all': { sort: [{ field: 'name', order: 'desc' }] } },
261283
});
284+
expect(byId.get(rowId('u1', RECORD_KEY))?.value).toEqual({ showEmptyFields: false });
262285
// A write found its row from the load: it asked the server nothing more.
263286
expect(wire().gets).toHaveLength(1);
264287
});
@@ -277,10 +300,10 @@ describe('console sys_user_preference request budget (objectui#12187)', () => {
277300

278301
const afterSwitch = wire().gets.slice(before);
279302
const forU2 = afterSwitch.filter((g) => g.user === 'u2');
280-
// One read for the new user, asking for all three keys and the prefix.
303+
// One read for the new user, asking for all three keys and both prefixes.
281304
expect(forU2).toHaveLength(1);
282305
expect([...forU2[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort());
283-
expect(forU2[0].prefixes).toEqual([LIST_PREFIX]);
306+
expect([...forU2[0].prefixes].sort()).toEqual(BOTH_PREFIXES);
284307
// No u2 read was answered with u1's rows: every consumer shows u2's.
285308
expect(latest.current).toEqual(U2_SEEN);
286309
});

0 commit comments

Comments
 (0)