Skip to content

Commit a7df45f

Browse files
fix(fields): the lookup cell names the referenced record from the fields the viewer may read (objectui#10501) (#10592)
Fixes #10501 Clause-②: no ## What changes `LookupCellRenderer` names the record a lookup points at through the referenced object's schema: `displayField`, then `nameField`, then `titleFormat`, then the type-aware derivation. It did that on the row as served. On a backend that does not strip policy-denied keys, a `titleFormat` or `nameField` naming a denied field printed that field in every lookup cell. - `resolveLookupRecordName` now resolves from the row with the fields the loaded policy denies on the REFERENCED object removed. `id` and `_id` are kept. This is the objectui#10411 / objectui#10434 rule, so the ladder falls through exactly as it does for the row a stripping backend (`FieldMasker`) serves. - The policy is a **required** parameter of `resolveLookupRecordName`. So every caller gets the gated row, and the compiler refuses a new caller that leaves it out. The callers are the expanded record, the JSON-encoded reference, each multi-value chip and the overflow chip's `title`, and the fetch-on-demand path. - The gate is one module-private copy, `withoutDeniedFields(record, policy, objectName)`, in `packages/fields/src/index.tsx`. `LookupField.tsx`'s helpers are not in reach: they are module-private in a file the package entry re-exports whole (`export * from './widgets/LookupField.js'`). Reaching them would mean editing `LookupField.tsx` and adding a new internal module, and both are outside the claim's file surface. See the census below for the consolidation question. - No new public export. `pnpm check:unreferenced-sources` and `check:phantom-deps` are green. `@object-ui/permissions` was already a declared dependency, and it is already in the entry's static graph through `LookupField.tsx`. ## Route change, measured: "`perms` in the effect's deps" would not relabel The triage and the claim put `perms` in the resolving effect's deps. I measured that mechanism before building it, and it does not relabel. The fetch-on-demand hook cached the resolved NAME in a module-level map, and its effect returns early on a settled entry. A policy change re-runs the effect, the effect returns at once, and the stale name stays. So the cache now holds the fetched **record and schema**, and the name is resolved on every render with the render's policy. That is the shape objectui#10487 (PR objectui#10557) gave the lookup editor's hydrated chip. A policy that loads or changes after the record arrived relabels the mounted cell with no second read. The effect only fetches, so it has no `perms` dependency to carry. The ablation M2b below is the evidence: I put the name back in the effect WITH `policy` in its deps, and the relabel pin went red. The cache key still carries `displayField` (objectui#2926 ⑧). It is no longer needed now that the entry holds a record, but dropping it would change how many reads a screen makes, and this card does not make that change. ## Reproduction (before the fix) I wrote the new pin first and ran it on base `8740e86ce`: **7 failed, 2 passed**. The 2 that passed are the controls. Each denied-field row failed on the served value, for example: ```text AssertionError: expected '...' not to contain 'ada@example.com' Received: "SPAN class="block max-w-full truncate" title="ada@example.com"...ada@example.com" ``` (The tag in the received line is spelled as a word so the text survives this page.) ## The pin — `packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsx` The tests use the real `PermissionProvider` and a backend that does not strip. Each test uses its own referenced object, because the caches are module-level. - An expanded record whose `titleFormat` names a denied field prints the name, and the text is the same as the text a stripping backend's row gives. - An expanded record whose `nameField` is denied prints the name. - A bare id fetched on demand: the fetched record is named from its readable fields. - A multi-value cell: no chip and no overflow `title` prints a denied value. - A JSON-encoded reference prints the name. - **Lit controls:** a field the policy does not deny still prints, on the expanded path and the fetched path. With no provider (`isLoaded` false), the row is named as served. - **A policy that arrives after mount relabels the SAME mounted label node** (identity asserted), on the expanded path and the fetched path. The fetched path asserts `findOne` was called once: relabelling reads the policy again, not the backend. ## Ablations: mutated on disk, restored by blob Each leg ran on committed `HEAD` `4c1bad224`. The mutation went through `ablation-replace.mjs` (anchor must hit, blob must change), was checked with an on-disk `grep -c` marker, and was restored from `HEAD`. The restore was proven by blob `9e540c8cffee` == `HEAD` blob and an empty `git diff HEAD`. The test imports `../index` (source), so no `dist` is involved. | leg | mutation | marker | result | |---|---|---|---| | M1 | gate removed (`if (true` or the rest) | 1 | **7 failed / 2 passed**: every denied-field and relabel row is red, both controls green | | M2a | name resolved inside the fetch effect, policy NOT in deps | 1 | **1 failed / 8 passed**: only "relabels … a bare id fetched on demand" | | M2b | the same, WITH `policy` in the effect deps (the triage's mechanism) | 1 + 1 | **1 failed / 8 passed**: same row, same stale `title="ada@example.com"` | | restored | none | 0 | 9 passed | ## Verification All the checks below ran at final `HEAD` **`a0885627b`**, from the repo root. The closure was built first with `turbo run build --filter=@object-ui/fields^...`. - `pnpm exec vitest run packages/fields/`: **201 files passed, 1 skipped; 3315 tests passed, 7 skipped**. The pin alone: 9/9. - `pnpm --filter @object-ui/fields run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `tsc -p tsconfig.test.json --listFiles` includes the new test file (count 1). - eslint over the two touched files, with `--no-inline-config --format json` (2 files linted, 0 errors): - `index.tsx`: the per-rule counts are identical to base `8740e86ce`: `react-refresh/only-export-components` 93, `no-explicit-any` 44, `no-unused-vars` 1. **`react-hooks/exhaustive-deps` is 0 before and 0 after.** - The new test file has 0 messages. - The config enables no type-aware linting (`tseslint.configs.recommended`, no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. - These gates exit 0: `check:control-bytes`; `check:new-line-citations` (0 new); `check-changeset-presence` (1 changeset, `@object-ui/fields` patch); `check-changeset-no-major`; `check-changeset-overwrite`; `check-type-check-coverage`; `check:phantom-deps`; `check:esm-specifiers` (specifiers only); `check-test-path-roots`; `check-vi-mock-override-shape`; `check:self-import`; `check:unreferenced-sources`. - `check:changeset-claims` flags 4 pending changesets that name `packages/fields/src/index.tsx`: 6625, 6694, 6837 and 7166. I read each paragraph. None is falsified: this diff adds no field-meta read and does not touch `decimals`. - NOT MEASURED locally: the eager-closure budget (it needs built artifacts, so CI runs it). `@object-ui/permissions` was already in the entry's static graph through `LookupField.tsx`. ## Census: every copy of "the row as the viewer may read it" (for the seat's decision) The claim asks for this census and bars an export on this card. Five copies are on `main` after this PR, and a sixth is in open PR objectui#10570. | file | symbol | exported? | keeps | gate source | shape | |---|---|---|---|---|---| | `packages/fields/src/widgets/LookupField.tsx` | `fieldReadGate` + `withoutDeniedFields(record, readable)` | module-private (the file is `export *`'d by the entry) | `id`, `_id`, declared `idField` | `usePermissions()` | a gate closure plus a row filter (two functions) | | `packages/fields/src/widgets/RecordPickerDialog.tsx` | `withoutDeniedFields(record, perms, objectName, idField)` | module-private (the file is `export *`'d by the entry) | `id`, `_id`, `idField` | `usePermissions()` | row filter | | `packages/fields/src/index.tsx` (this PR) | `withoutDeniedFields(record, policy, objectName)` | module-private | `id`, `_id` | `usePermissions()`, typed as the Pick of `isLoaded`/`checkField` | row filter | | `packages/plugin-detail/src/withoutDeniedFields.ts` | `withoutDeniedFields(record, perms, objectName)` | a module export, not a package export | `id`, `_id` | `usePermissions()`, the same Pick | row filter | | `packages/app-shell/src/views/RecordDetailView.tsx` | `withoutDeniedFields(record, perms, objectName)` | module-private | `id`, `_id` | `usePermissions()`, the same Pick | row filter | | `packages/react/src/hooks/useRecordSearch.ts` (open PR objectui#10570, not on `main`) | `readableRow(record, objectName, policy)` | module-private | `id`, `_id` | the caller passes a structural `fieldReadPolicy` (react does not depend on permissions) | row filter, argument order differs | The six share one body: return the row unchanged unless the policy is loaded and an object is named. Keep `id`, `_id` and any extra identity key. Drop every key for which `checkField(object, key, 'read')` is false. Return the SAME object when nothing is withheld. One pure export would replace all six. Its signature would be `(record, policy, objectName, extraKeep?)`, where `policy` is structural and needs only `isLoaded` and `checkField`. The differences it has to absorb are the `idField` extra key in two fields copies, the closure split in `LookupField`, and the argument order in react. The dependency facts that decide where the export lives: - `@object-ui/permissions` is a runtime dependency of app-shell, fields, plugin-calendar, plugin-dashboard, plugin-detail, plugin-form, plugin-gantt, plugin-grid, plugin-kanban, plugin-list, plugin-map, plugin-timeline, plugin-tree and plugin-view. `apps/console` lists it only as a devDependency. - **`@object-ui/react` does not depend on it.** `@object-ui/permissions` depends only on `@object-ui/types`, so adding it to react would not create a cycle. - `@object-ui/core` is already a dependency of fields, plugin-detail, app-shell and react. A structural-policy export there would need no new edge anywhere. An export from `@object-ui/permissions` would need react to add one edge. A different shape is **not** covered by a row export: the column/field-LIST filters. These are `PeoplePicker`'s `$expand`, subtitle and avatar filters, `LookupField`'s `readablePreviewColumns` and expand list, and the `keepReadableColumns` family in `RelatedList` and the grids. They filter a list of names, not a row. ## Acceptance notes - These notes are not filed and are not in scope. - `UserCellRenderer` names an embedded user from `name` / `username` / `image` off the row as served. It does not go through a schema or a policy. A policy that denies `name` on the user object would still print it. I did not probe this: there is no repro and no claim that such a policy exists. Terms a later search could use: `UserCellRenderer` `denied name` and `user cell FLS`. - The cache key keeps `displayField` (see above). Dropping it would save one read when two columns point at the same record with different display fields. That is a fetch-count change, not a correctness change. ## Overlap - PR objectui#10568 edits the masked-cell region of the same file (`MaskedCellRenderer` / `buildStandardCellRendererMap`). This PR touches only the lookup resolver, `useLookupName` and `LookupCellRenderer`, so the hunks are disjoint. - The live claim objectui#10493 (`ImageCellRenderer`): this PR does not touch it. This body was written by `https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC` (the `domain:ui` seat 1 dispatch). --- _Generated by [Claude Code](https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1daf477 commit a7df45f

3 files changed

Lines changed: 385 additions & 21 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
fix(fields): the lookup cell names the referenced record from the fields the viewer may read (objectui#10501)
6+
7+
`LookupCellRenderer` names the record a lookup points at through the referenced
8+
object's schema: the column's `displayField`, then the object's `nameField`,
9+
then its `titleFormat`, then the type-aware derivation. It did that on the row
10+
as served. On a backend that does not strip the fields a permission policy
11+
denies (ObjectStack's `FieldMasker` does strip them), a `titleFormat` or
12+
`nameField` naming a denied field printed that field's value in every list,
13+
grid and detail cell that shows the lookup. The lookup editor's option label
14+
already applied the rule (objectui#10411), as does the record title
15+
(objectui#10434).
16+
17+
The cell now resolves the name from the row with the fields the loaded policy
18+
denies on the REFERENCED object removed; `id` and `_id` are kept. The name
19+
falls through to the next source exactly as it does for the row a stripping
20+
backend serves. Every shape the cell names a record from is covered: an
21+
expanded record, a JSON-encoded reference, each chip of a multi-value cell and
22+
the overflow chip's tooltip, and a bare id resolved on demand. With no policy
23+
loaded, or no `PermissionProvider` mounted, the row is named as served, as
24+
before.
25+
26+
A bare id resolved on demand used to cache the resolved NAME. It now caches the
27+
fetched record and schema, and the name is resolved on every render, the same
28+
shape objectui#10487 gave the lookup editor's hydrated chip. So a policy that
29+
loads or changes after the record arrived relabels the mounted cell, with no
30+
second read of the record.
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The lookup CELL names the referenced record from the fields the viewer may
11+
* read — objectui#10501.
12+
*
13+
* `LookupCellRenderer` resolves the referenced record's display name through
14+
* the referenced object's schema (`displayField` → `nameField` → `titleFormat`
15+
* → derivation). It did that on the row as served, so on a backend that does
16+
* not strip denied keys (ObjectStack's `FieldMasker` does) a `titleFormat` or
17+
* `nameField` naming a field the loaded policy denies printed that field's
18+
* value in every list and grid cell. The lookup EDITOR's option label already
19+
* applied the rule (objectui#10411), and so does the record title
20+
* (objectui#10434): a display value is built from the row with the denied
21+
* fields removed, `id` kept.
22+
*
23+
* What is pinned, against the real `PermissionProvider` (not a stub), on the
24+
* REFERENCED object's policy:
25+
*
26+
* - every shape the cell names a record from — an expanded record, a bare id
27+
* fetched on demand, a multi-value cell (chips and the overflow title) and
28+
* a JSON-encoded reference — prints no denied field, and names the record
29+
* exactly as a stripping backend's row names it;
30+
* - lit controls: a field the policy does NOT deny still prints, and with no
31+
* policy loaded (no provider: `isLoaded` is false) the row is named as
32+
* served — so the pins above read a GATE, not a name that went missing;
33+
* - a policy that arrives after mount relabels the SAME mounted cell, on the
34+
* expanded path and on the fetched path.
35+
*
36+
* Every test names its own referenced object: the cell's record and schema
37+
* caches are module-level and outlive a test.
38+
*/
39+
40+
import * as React from 'react';
41+
import { describe, it, expect, vi, afterEach } from 'vitest';
42+
import { render, screen, cleanup, waitFor, act, fireEvent, within } from '@testing-library/react';
43+
import '@testing-library/jest-dom';
44+
import { SchemaRendererProvider } from '@object-ui/react';
45+
import { PermissionProvider } from '@object-ui/permissions';
46+
import type { DataSource, FieldMetadata, ObjectPermissionConfig } from '@object-ui/types';
47+
import { LookupCellRenderer } from '../index';
48+
49+
const ADA = { id: 'p1', name: 'Ada Lovelace', email: 'ada@example.com', phone: '555-0100' };
50+
const PEOPLE = [
51+
ADA,
52+
{ id: 'p2', name: 'Grace Hopper', email: 'grace@example.com', phone: '555-0101' },
53+
{ id: 'p3', name: 'Alan Turing', email: 'alan@example.com', phone: '555-0102' },
54+
{ id: 'p4', name: 'Edsger Dijkstra', email: 'edsger@example.com', phone: '555-0103' },
55+
];
56+
const EMAILS = PEOPLE.map((p) => p.email);
57+
58+
/** `row` as a stripping backend (ObjectStack's `FieldMasker`) serves it. */
59+
function stripped<T extends Record<string, unknown>>(row: T, ...denied: string[]): T {
60+
const copy: Record<string, unknown> = { ...row };
61+
for (const field of denied) delete copy[field];
62+
return copy as T;
63+
}
64+
65+
/**
66+
* A backend that does NOT strip denied keys — the case this gate defends.
67+
* `findOne` serves `records` by id; `getObjectSchema` serves `schema`.
68+
*/
69+
function makeDataSource(objectName: string, schema: Record<string, unknown>, records: Record<string, unknown>[] = []) {
70+
return {
71+
find: vi.fn(async () => ({ data: [] })),
72+
findOne: vi.fn(async (object: string, id: string) =>
73+
object === objectName ? records.find((r) => r.id === id) ?? null : null,
74+
),
75+
getObjectSchema: vi.fn(async (object: string) => (object === objectName ? schema : undefined)),
76+
};
77+
}
78+
79+
/** The double implements only the three reads the cell makes. */
80+
type DataSourceDouble = ReturnType<typeof makeDataSource>;
81+
82+
/** The real role-based policy, denying the named fields of `objectName` to `viewer`. */
83+
function policyDenying(objectName: string, ...fields: string[]): ObjectPermissionConfig[] {
84+
return [
85+
{
86+
object: objectName,
87+
roles: {
88+
viewer: { actions: ['read'], fieldPermissions: fields.map((field) => ({ field, read: false })) },
89+
},
90+
} as ObjectPermissionConfig,
91+
];
92+
}
93+
94+
function Cell({
95+
ds,
96+
objectName,
97+
value,
98+
policy,
99+
}: {
100+
ds: DataSourceDouble;
101+
objectName: string;
102+
value: unknown;
103+
/** Omitted: no provider mounted, so no policy is loaded. */
104+
policy?: ObjectPermissionConfig[];
105+
}) {
106+
const cell = (
107+
<div data-testid="cell">
108+
<SchemaRendererProvider dataSource={ds as unknown as DataSource}>
109+
<LookupCellRenderer value={value} field={{ type: 'lookup', reference_to: objectName } as FieldMetadata} />
110+
</SchemaRendererProvider>
111+
</div>
112+
);
113+
if (!policy) return cell;
114+
return (
115+
<PermissionProvider roles={[]} userRoles={['viewer']} permissions={policy}>
116+
{cell}
117+
</PermissionProvider>
118+
);
119+
}
120+
121+
async function settle(): Promise<void> {
122+
for (let i = 0; i < 6; i++) {
123+
await act(async () => {
124+
await new Promise((r) => setTimeout(r, 0));
125+
});
126+
}
127+
}
128+
129+
/** Render one cell, let its schema (and any on-demand record) land, return what it drew. */
130+
async function drawn(props: React.ComponentProps<typeof Cell>): Promise<{ text: string; html: string }> {
131+
render(<Cell {...props} />);
132+
await waitFor(() => expect(props.ds.getObjectSchema).toHaveBeenCalledWith(props.objectName));
133+
await settle();
134+
const el = screen.getByTestId('cell');
135+
const out = { text: el.textContent ?? '', html: el.innerHTML };
136+
cleanup();
137+
return out;
138+
}
139+
140+
afterEach(() => {
141+
cleanup();
142+
});
143+
144+
describe('LookupCellRenderer — the referenced record is named from the fields the viewer may read (objectui#10501)', () => {
145+
it('an expanded record: a denied `titleFormat` field is not printed; the cell reads as a stripping backend makes it read', async () => {
146+
const gated = await drawn({
147+
ds: makeDataSource('rg_person_tf', { titleFormat: '{email}' }),
148+
objectName: 'rg_person_tf',
149+
value: ADA,
150+
policy: policyDenying('rg_person_tf', 'email'),
151+
});
152+
const served = await drawn({
153+
ds: makeDataSource('rg_person_tf_strip', { titleFormat: '{email}' }),
154+
objectName: 'rg_person_tf_strip',
155+
value: stripped(ADA, 'email'),
156+
});
157+
158+
expect(gated.html).not.toContain('ada@example.com');
159+
expect(gated.text).toBe('Ada Lovelace');
160+
expect(gated.text).toBe(served.text);
161+
});
162+
163+
it('an expanded record: a denied `nameField` is not printed', async () => {
164+
const gated = await drawn({
165+
ds: makeDataSource('rg_person_nf', { nameField: 'email' }),
166+
objectName: 'rg_person_nf',
167+
value: ADA,
168+
policy: policyDenying('rg_person_nf', 'email'),
169+
});
170+
171+
expect(gated.html).not.toContain('ada@example.com');
172+
expect(gated.text).toBe('Ada Lovelace');
173+
});
174+
175+
it('a bare id fetched on demand: the fetched record is named from its readable fields', async () => {
176+
const ds = makeDataSource('rg_person_fetch', { titleFormat: '{email}' }, PEOPLE);
177+
render(<Cell ds={ds} objectName="rg_person_fetch" value="p1" policy={policyDenying('rg_person_fetch', 'email')} />);
178+
179+
await waitFor(() => expect(screen.getByTestId('cell')).toHaveTextContent('Ada Lovelace'));
180+
await settle();
181+
expect(ds.findOne).toHaveBeenCalledWith('rg_person_fetch', 'p1');
182+
expect(screen.getByTestId('cell').innerHTML).not.toContain('ada@example.com');
183+
});
184+
185+
it('a multi-value cell: every chip, and the overflow chip title, is named from the readable fields', async () => {
186+
const gated = await drawn({
187+
ds: makeDataSource('rg_person_many', { titleFormat: '{email}' }),
188+
objectName: 'rg_person_many',
189+
value: PEOPLE,
190+
policy: policyDenying('rg_person_many', 'email'),
191+
});
192+
193+
for (const email of EMAILS) expect(gated.html).not.toContain(email);
194+
expect(gated.text).toBe('Ada LovelaceGrace HopperAlan Turing+1');
195+
expect(gated.html).toContain('title="Edsger Dijkstra"');
196+
});
197+
198+
it('a JSON-encoded reference is named from the readable fields', async () => {
199+
const gated = await drawn({
200+
ds: makeDataSource('rg_person_json', { titleFormat: '{email}' }),
201+
objectName: 'rg_person_json',
202+
value: JSON.stringify(ADA),
203+
policy: policyDenying('rg_person_json', 'email'),
204+
});
205+
206+
expect(gated.html).not.toContain('ada@example.com');
207+
expect(gated.text).toBe('Ada Lovelace');
208+
});
209+
210+
it('control: a field the policy does NOT deny still prints, expanded and fetched', async () => {
211+
const expanded = await drawn({
212+
ds: makeDataSource('rg_person_lit', { titleFormat: '{email}' }),
213+
objectName: 'rg_person_lit',
214+
value: ADA,
215+
policy: policyDenying('rg_person_lit', 'phone'),
216+
});
217+
expect(expanded.text).toBe('ada@example.com');
218+
219+
const ds = makeDataSource('rg_person_lit_fetch', { titleFormat: '{email}' }, PEOPLE);
220+
render(<Cell ds={ds} objectName="rg_person_lit_fetch" value="p1" policy={policyDenying('rg_person_lit_fetch', 'phone')} />);
221+
await waitFor(() => expect(screen.getByTestId('cell')).toHaveTextContent('ada@example.com'));
222+
});
223+
224+
it('control: with no policy loaded (no provider), the row is named as served', async () => {
225+
const served = await drawn({
226+
ds: makeDataSource('rg_person_none', { titleFormat: '{email}' }),
227+
objectName: 'rg_person_none',
228+
value: ADA,
229+
});
230+
expect(served.text).toBe('ada@example.com');
231+
});
232+
233+
describe('a policy that arrives after mount relabels the same mounted cell', () => {
234+
function Host({ ds, objectName, value }: { ds: DataSourceDouble; objectName: string; value: unknown }) {
235+
const [policy, setPolicy] = React.useState<ObjectPermissionConfig[]>([]);
236+
return (
237+
<>
238+
<button type="button" data-testid="deny-email" onClick={() => setPolicy(policyDenying(objectName, 'email'))} />
239+
<Cell ds={ds} objectName={objectName} value={value} policy={policy} />
240+
</>
241+
);
242+
}
243+
244+
async function relabels(ds: DataSourceDouble, objectName: string, value: unknown): Promise<void> {
245+
render(<Host ds={ds} objectName={objectName} value={value} />);
246+
await waitFor(() => expect(screen.getByTestId('cell')).toHaveTextContent('ada@example.com'));
247+
await settle();
248+
const label = within(screen.getByTestId('cell')).getByTitle('ada@example.com');
249+
250+
await act(async () => {
251+
fireEvent.click(screen.getByTestId('deny-email'));
252+
});
253+
await settle();
254+
255+
// The same mounted label, relabelled in place — not a remount.
256+
expect(within(screen.getByTestId('cell')).getByTitle('Ada Lovelace')).toBe(label);
257+
expect(screen.getByTestId('cell').innerHTML).not.toContain('ada@example.com');
258+
}
259+
260+
it('an expanded record', async () => {
261+
await relabels(makeDataSource('rg_person_late', { titleFormat: '{email}' }), 'rg_person_late', ADA);
262+
});
263+
264+
it('a bare id fetched on demand', async () => {
265+
const ds = makeDataSource('rg_person_late_fetch', { titleFormat: '{email}' }, PEOPLE);
266+
await relabels(ds, 'rg_person_late_fetch', 'p1');
267+
// Relabelling re-reads the policy, not the backend.
268+
expect(ds.findOne).toHaveBeenCalledTimes(1);
269+
});
270+
});
271+
});

0 commit comments

Comments
 (0)