Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/10778-dashboard-bus-readers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,12 @@ over `{ provider: 'object' }` and what a drill-down drawer lists the underlying
records with, so those re-read too. A drill that opens a report
(`drillDown.report`) and the single-record drawer a table row opens do not
re-read. Bound rows and authored `data` rows do not subscribe.

⚠️ **Dated note, 2026-09-27 — the report a drill opens does re-read — objectui#10853.**
The sentence above that a drill opening a report (`drillDown.report`) does not
re-read stopped holding with objectui#10814 (PR objectui#10836), pending in the
same release: the drawer's `drillDown.report` arm renders `spec-report` through
`SchemaRenderer`, and the dataset report renderer's own reader re-reads it on the
bus. `drillDownReport.invalidationRefetch-10814.test.tsx` in `apps/console` pins
that through the real drawer. The single-record drawer a table row opens still
does not re-read. The rest of this entry is kept as the reading of this change.
31 changes: 31 additions & 0 deletions .changeset/10853-master-detail-picker-bus-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@object-ui/plugin-form': patch
'@object-ui/components': patch
---

fix(plugin-form,components): an edit-mode `object-master-detail-form`'s detail lines and `element:record_picker`'s options re-read on the data-invalidation bus

An `object-master-detail-form` in edit mode now reads the bus for each detail
collection's child object: a write declared there (`notifyDataChanged`, as a
page action over raw HTTP does), or an unscoped `'*'`, re-reads that
collection's lines in place, and the rows and the baseline the next save diffs
against move together. A collection only re-reads for its own child object. The
header already re-read through its own form. While a collection holds lines the
user has changed since they were last read or saved (compared the way the save
compares rows, with the link to the parent set aside), or while the row editor
("Open row") is open on it, its re-read is held. It runs once, when the row
editor is closed and either the lines have been changed back or this form's
save has landed. A line typed while a re-read is in flight, in the grid or in
the row editor, is kept, and the re-read is held behind it; an open row editor
is never reset by a re-read. A re-read that fails keeps the lines on screen.
`object-form` with `subforms`, which renders the same form, re-reads the same
way.

`element:record_picker` now re-reads its options when the bus reports a write to
the object it queries. The re-read keeps the control enabled over the options on
screen (no "Loading…") and never touches the bound page variable. If the bound
record is no longer among the options, the variable keeps its value and the
control shows no label until a later read offers that record again.

Before, both refreshed after such a write only when their host remounted them,
and `PageView` is about to stop doing that (objectui#10519).
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#10853 — `element:record_picker` re-reads its options when the
* data-invalidation bus (`notifyDataChanged` from `@object-ui/react`) reports a
* change to the object it queries, in place, and never touches the bound
* page-variable value.
*
* Before this card the option read ran only when the adapter or the query
* (object, filter, sort, limit) changed, so after a page action over raw HTTP
* the picker kept offering the pre-action records until its host remounted it,
* and `PageView`'s remount is what objectui#10519 removes.
*
* Rendered through the real `SchemaRenderer` and this package's own
* registration, inside a real `PageVariablesProvider` whose variable is bound
* to the picker. Reads answer at once until a case holds them, so the case can
* look at the picker while a re-read is in flight. The bare
* `useDataInvalidation` reader beside the picker is the positive control.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, act, cleanup, waitFor, screen } from '@testing-library/react';
import {
AdapterCtx,
PageVariablesProvider,
SchemaRenderer,
notifyDataChanged,
useDataInvalidation,
usePageVariables,
} from '@object-ui/react';
// Registers `element:record_picker` at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

type Row = Record<string, any>;

interface HeldRead {
objectName: string;
resolve: (value: unknown) => void;
}

function makeAdapter(stored: Row[]) {
const held: HeldRead[] = [];
const state = { hold: false, rows: stored };
const adapter = {
find: vi.fn((objectName: string) => {
if (state.hold) {
return new Promise((resolve) => {
held.push({ objectName, resolve });
});
}
return Promise.resolve({ data: state.rows.map((r) => ({ ...r })) });
}),
};
return { adapter, held, state };
}

/** The positive control: a bare reader of the picker's object. */
function BusControl() {
const nonce = useDataInvalidation('account');
return <span data-testid="bus-control">{nonce}</span>;
}

/** What the page holds in the variable the picker writes. */
function BoundValue() {
const { variables } = usePageVariables();
return <span data-testid="bound-value">{String(variables.sel)}</span>;
}

const PICKER = { type: 'element:record_picker', id: 'picker', properties: { object: 'account' } };

function mountPicker(stored: Row[], node: Record<string, unknown> = PICKER) {
const made = makeAdapter(stored);
render(
<AdapterCtx.Provider value={made.adapter as never}>
<PageVariablesProvider definitions={[{ name: 'sel', type: 'record_id', source: 'picker', defaultValue: 'a1' } as never]}>
<BusControl />
<BoundValue />
<SchemaRenderer schema={node as never} />
</PageVariablesProvider>
</AdapterCtx.Provider>,
);
return made;
}

async function settle(fn: () => void = () => {}) {
await act(async () => {
fn();
await Promise.resolve();
});
}
const rest = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 30)));
const emit = (change: { objectName: string; recordId?: string }) => settle(() => notifyDataChanged(change));

const trigger = () => screen.getByTestId('record-picker-trigger') as HTMLButtonElement;
const boundValue = () => screen.getByTestId('bound-value').textContent;

async function mountAtRest(stored: Row[] = [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }]) {
const made = mountPicker(stored);
await waitFor(() => expect(made.adapter.find).toHaveBeenCalledTimes(1));
await waitFor(() => expect(trigger().textContent).toBe('Acme'));
await rest();
return made;
}

describe('element:record_picker re-reads its options on the data-invalidation bus (objectui#10853)', () => {
it('an unscoped change ("*") re-reads once, in place, keeping the bound value', async () => {
const { adapter, held, state } = await mountAtRest();
const triggerNode = trigger();
state.hold = true;

await emit({ objectName: '*' });

expect(screen.getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('1');
await waitFor(() => expect(adapter.find, 'the options never re-read after the bus reported a change').toHaveBeenCalledTimes(2));
expect(adapter.find.mock.calls[1][0]).toBe('account');
// In flight: the same control, enabled, still showing the bound record.
expect(trigger(), 'the re-read remounted the picker').toBe(triggerNode);
expect(trigger().disabled, 'the re-read disabled the picker').toBe(false);
expect(trigger().textContent, 'the re-read blanked the picker to "Loading…"').toBe('Acme');
expect(boundValue()).toBe('a1');

await settle(() => held[0].resolve({ data: [{ id: 'a1', name: 'Acme (renamed)' }, { id: 'a2', name: 'Globex' }] }));
await waitFor(() => expect(trigger().textContent).toBe('Acme (renamed)'));
expect(trigger()).toBe(triggerNode);
expect(boundValue(), 'the re-read changed the bound page-variable value').toBe('a1');
});

it('a change to its object re-reads once; an unrelated object does not', async () => {
const { adapter } = await mountAtRest();

await emit({ objectName: 'unrelated_object' });
await rest();
expect(adapter.find, 'a change to another object re-read the options').toHaveBeenCalledTimes(1);

await emit({ objectName: 'account', recordId: 'a2' });
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2));
await rest();
expect(adapter.find, 'one change re-read the options more than once').toHaveBeenCalledTimes(2);
});

it('the bound record gone from the re-read options: the value is kept, the control shows no label, and the label returns with the record', async () => {
const { adapter, state } = await mountAtRest();

state.rows = [{ id: 'a2', name: 'Globex' }];
await emit({ objectName: 'account' });
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2));
await rest();

expect(boundValue(), 'the re-read cleared the bound page-variable value').toBe('a1');
// The value names no offered record: neither the old label nor the
// placeholder is drawn (a value is set, so the placeholder does not apply).
expect(trigger().textContent).toBe('');
expect(trigger().disabled).toBe(false);

state.rows = [{ id: 'a1', name: 'Acme' }, { id: 'a2', name: 'Globex' }];
await emit({ objectName: 'account' });
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(3));
await waitFor(() => expect(trigger().textContent).toBe('Acme'));
expect(boundValue()).toBe('a1');
});

it('control: a picker with no object reads nothing, on mount or on an invalidation', async () => {
const { adapter } = mountPicker([{ id: 'a1', name: 'Acme' }], { type: 'element:record_picker', id: 'picker', properties: {} });
await rest();

await emit({ objectName: '*' });
await rest();

expect(screen.getByTestId('bus-control').textContent).toBe('1');
expect(adapter.find).not.toHaveBeenCalled();
});
});
42 changes: 37 additions & 5 deletions packages/components/src/renderers/basic/record-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ElementDataSourceErrorPanel,
ElementDataSourceLoadingPanel,
useAdapter,
useDataInvalidation,
useElementDataSource,
usePageVariableBinding,
useFilterScope,
Expand Down Expand Up @@ -135,14 +136,39 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
// with the same entries is not a change (AGENTS.md #10).
const sortKey = React.useMemo(() => (sort ? JSON.stringify(sort) : ''), [sort]);

// objectui#10853 — the data-invalidation bus (`notifyDataChanged` from
// `@object-ui/react`), read the objectui#10623 / objectui#10778 way: the
// nonce moves when the bus reports a change to the object this picker
// QUERIES (or `'*'`), and the fetch effect below names it, so the options are
// re-read. Before, a page action over raw HTTP left them stale unless the
// host remounted the picker, and `PageView` is to stop doing that
// (objectui#10519). Subscribed only when the effect can query (an adapter
// that reads, and an object resolved).
const invalidationNonce = useDataInvalidation(
adapter && typeof adapter.find === 'function' ? object : undefined,
);
// The adapter and query the options on screen answer, for telling a re-read
// of them from a read of other options. Written when a read commits.
const committedReadRef = React.useRef<{ adapter: unknown; signature: string } | null>(null);

React.useEffect(() => {
let cancelled = false;
if (!adapter || !object || typeof adapter.find !== 'function') {
setLoading(false);
return;
}
setLoading(true);
setError(null);
// objectui#10853 — a read of exactly the query whose options are on
// screen (a bus re-read) is IN PLACE: the control stays enabled over those
// options and the bound page-variable value is not touched, and the answer
// swaps them when it lands. Any other read starts from "Loading…", as it
// always has.
const signature = JSON.stringify([object, filterKey, sortKey, limit]);
const committed = committedReadRef.current;
const inPlace = committed !== null && committed.adapter === adapter && committed.signature === signature;
if (!inPlace) {
setLoading(true);
setError(null);
}
(async () => {
try {
const query: any = {};
Expand All @@ -159,7 +185,13 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
// it here. Pinned by
// `record-picker.contractEnvelope-6726.test.tsx`.
const data: any[] = res?.data ?? (Array.isArray(res) ? res : []);
if (!cancelled) setRows(data);
if (!cancelled) {
setRows(data);
// An in-place re-read that lands clears an earlier failure: the
// options on screen answer the query now.
setError(null);
committedReadRef.current = { adapter, signature };
}
} catch (e: any) {
if (!cancelled) setError(e?.message ?? 'Failed to load');
} finally {
Expand All @@ -170,7 +202,7 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adapter, object, filterKey, sortKey, limit]);
}, [adapter, object, filterKey, sortKey, limit, invalidationNonce]);

// Reflect the bound variable's value back into the control. When a variable
// targets this picker we stay controlled for its whole lifetime (empty string
Expand Down Expand Up @@ -402,7 +434,7 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord
// legal write this input's own description teaches (objectui#5637).
type: ['string', 'object'],
description:
'Prompt shown in the closed control while no record is selected (renderer default "Select a record…"). Display-only — it never reaches the query. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), the `I18nLabel` union rc.6 widened this key to; the renderer resolves the map against the active language at the read site, falling back through base language, a region-qualified sibling, `default`, then `en`. It is REPLACED while the picker is busy: "Loading…" during the fetch and "Failed to load" after an error both win over this key. An authored empty string stays empty; the default applies only when the key is absent.',
'Prompt shown in the closed control while no record is selected (renderer default "Select a record…"). Display-only — it never reaches the query. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), the `I18nLabel` union rc.6 widened this key to; the renderer resolves the map against the active language at the read site, falling back through base language, a region-qualified sibling, `default`, then `en`. It is REPLACED while the picker is busy: "Loading…" while it reads a new set of options and "Failed to load" after an error both win over this key (a re-read of the same options after a data change keeps them on screen and shows no "Loading…"). An authored empty string stays empty; the default applies only when the key is absent.',
},
{
name: 'label',
Expand Down
Loading
Loading