diff --git a/.changeset/10657-fields-masked-cell-renderer-export.md b/.changeset/10657-fields-masked-cell-renderer-export.md
new file mode 100644
index 0000000000..21d85361ca
--- /dev/null
+++ b/.changeset/10657-fields-masked-cell-renderer-export.md
@@ -0,0 +1,19 @@
+---
+'@object-ui/fields': minor
+---
+
+`MaskedCellRenderer`, the mask a `password` / `secret` cell draws, is now exported, so a table that cannot type a column yet draws it WITHHELD instead of as text (objectui#10657, which folded objectui#10706).
+
+**New export `MaskedCellRenderer`.** It is the component the standard `password` and
+`secret` cells already resolve to: `••••••` for a value that is set, the empty-value mark
+for one that is not. Its `field` prop is optional, because it reads only whether a value
+is present. It is the shipped mask, not whatever is registered for `password` at the
+time, so a host override of a masked type does not change what a withheld cell draws.
+
+`object-grid`, the related list and `object-data-table` use it for a column whose type is
+not known yet: while the object's field types are loading, or after their read failed,
+they draw such a column as this mask rather than as its value.
+
+`registerFieldRenderer('api_token', MaskedCellRenderer)` now masks a type the same way
+the `getCellRenderer('password')` form does, and `isMaskedFieldType('api_token')` answers
+`true` for it. Nothing that already resolves changes.
diff --git a/.changeset/10657-plugin-dashboard-withheld-draw.md b/.changeset/10657-plugin-dashboard-withheld-draw.md
new file mode 100644
index 0000000000..2ea72f966b
--- /dev/null
+++ b/.changeset/10657-plugin-dashboard-withheld-draw.md
@@ -0,0 +1,29 @@
+---
+'@object-ui/plugin-dashboard': patch
+---
+
+`object-data-table` no longer draws an untyped column as text while the object definition is loading or after its read failed: it draws the mask instead (objectui#10657, the objectui#10706 class at this producer).
+
+A cell here draws from the column's authored `type`, or the object's. Bound and inline
+rows are drawn before the object definition arrives, and a failed read never delivers
+one, so in that window a column that authors no `type` drew its value as text, a
+`password` / `secret` field included. objectui#10657's first change already set
+`TableColumn.masked` on every column in that window, but the flag withholds; it does not
+draw.
+
+Now, in that window, a column with no `type` of its own is drawn as the mask
+(`MaskedCellRenderer` from `@object-ui/fields`), declared and derived columns alike, and
+stays so when the read failed: fail closed, never back to text. A column that authors its
+`type` draws from it, and a column with a `cell` of its own draws what that `cell`
+returns. Once the definition lands, each column draws from its declared type as before.
+
+The record drawer a row opens (record drill-down) drew the clicked record's fields from
+the same unknown types, so after a failed read it printed a `password` / `secret` field
+as text too. In that window it now draws every value as the mask.
+
+The pending objectui#10657 `object-data-table` changeset says a `password` / `secret`
+field is still drawn as text in that window; that no longer holds once this change ships
+with it.
+
+This is defence in depth. The real guard is still the server: ObjectStack's read mask
+replaces `secret` and `password` values with its mask before they reach the browser.
diff --git a/.changeset/10657-plugin-detail-withheld-draw.md b/.changeset/10657-plugin-detail-withheld-draw.md
new file mode 100644
index 0000000000..92c158ab36
--- /dev/null
+++ b/.changeset/10657-plugin-detail-withheld-draw.md
@@ -0,0 +1,26 @@
+---
+'@object-ui/plugin-detail': patch
+---
+
+The related list no longer draws a cell as text while its object definition is loading or after its read failed: it draws the mask instead (objectui#10657, the objectui#10706 class at this producer).
+
+The list draws a cell from the object's field type, never from a column's authored
+`type`. While the definition was in flight, and for good after its read failed, no column
+had a cell, so the table drew every value as text, a `password` / `secret` field
+included. objectui#10657's first change already set `TableColumn.masked` on every column
+in that window (no copy, tooltip or header sort), but the flag withholds; it does not
+draw.
+
+Now, in that window, every column without a `cell` of its own is drawn as the mask
+(`MaskedCellRenderer` from `@object-ui/fields`), and stays so when the read failed: fail
+closed, never back to text. A column whose `cell` the author supplied draws what that
+`cell` returns. Once the definition lands, each column draws from its declared type as
+before.
+
+The cost: while the definition is loading, and for good after a failed read, the list
+shows masks where it used to show values. The pending objectui#10657 related-list
+changeset says a `password` / `secret` field is still drawn as text in that window; that
+no longer holds once this change ships with it.
+
+This is defence in depth. The real guard is still the server: ObjectStack's read mask
+replaces `secret` and `password` values with its mask before they reach the browser.
diff --git a/.changeset/10657-plugin-grid-withheld-first-paint.md b/.changeset/10657-plugin-grid-withheld-first-paint.md
new file mode 100644
index 0000000000..1966c1d38c
--- /dev/null
+++ b/.changeset/10657-plugin-grid-withheld-first-paint.md
@@ -0,0 +1,39 @@
+---
+'@object-ui/plugin-grid': minor
+---
+
+`object-grid` no longer draws an untyped column as text before its object schema has loaded: the column is WITHHELD until it has, and stays withheld when the read fails; a host can hand the grid the field types it holds (`objectFields`) so there is no such window (objectui#10657, which folded objectui#10706).
+
+On the host-fetched path (rows handed down as `data`, as `ListView` does) the rows paint
+before the grid's own `getObjectSchema` read lands. An untyped view column takes its type
+from that schema, so until it arrived a column over a `password` / `secret` field drew
+the raw value as text and handed it out (copy, tooltip, the grid's client export, the
+mobile card), and grouping by it printed the value as the group label. A failed read was
+swallowed as non-fatal, leaving the column in the clear for the life of the grid.
+
+- **Withheld.** While an object-bound grid (an object name, and a data source that can
+ answer `getObjectSchema`) has no field types in hand, a column with no `type` of its
+ own is drawn as the mask (`MaskedCellRenderer` from `@object-ui/fields`), carries
+ `TableColumn.masked`, is left out of the grid's client export, is drawn through its cell
+ on the mobile card, and cannot be a grouping key (the entry is ignored without a
+ warning until the types are known; a masked field is still warned about). No type is
+ inferred for it from its name or values meanwhile. It stays withheld when the read
+ fails: fail closed, never back to text. A column that authors its own `type` draws
+ from it, as it did. A grid with no `getObjectSchema` to wait for is unchanged.
+- **The record panel** a row opens under overlay navigation (drawer, modal, split,
+ popover) with no declared fields in hand printed every value of the record, inferred
+ from its value and name. While the types are unknown it now draws each value as the
+ mask as well.
+- **New prop `objectFields` on `ObjectGrid` (`ObjectGridComponentProps`).** The object's
+ field catalogue, handed down by a host that has already read the definition. The grid
+ answers every field-type question from it until its own read lands, so the columns
+ draw from their declared types at first paint and nothing is withheld. It is a runtime
+ prop only: `SchemaRenderer` strips an `objectFields` key authored in the schema
+ (objectui#8818), so no view author can hand the grid a catalogue.
+
+The pending objectui#10583 changeset lists the host-fetched window under "Not covered",
+and the pending objectui#10657 grid changeset says that window stays not covered. Neither
+holds once this change ships with them.
+
+This is defence in depth. The real guard is still the server: ObjectStack's read mask
+replaces `secret` and `password` values with its mask before they reach the browser.
diff --git a/.changeset/10657-plugin-list-grid-object-fields.md b/.changeset/10657-plugin-list-grid-object-fields.md
new file mode 100644
index 0000000000..10379f3b47
--- /dev/null
+++ b/.changeset/10657-plugin-list-grid-object-fields.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/plugin-list': patch
+---
+
+`ListView` hands its grid the object's field types together with the rows, so the grid draws a `password` / `secret` column masked from its first paint (objectui#10657, which folded objectui#10706).
+
+`ListView` fetches the rows and hands them to `object-grid` as `data`, and those rows
+painted before the grid's own read of the object definition settled. In that window an
+untyped column over a `password` / `secret` field had no type: it drew the raw value, and
+since `object-grid` now withholds such a column instead, it would draw the mask for every
+untyped column until the read lands. `ListView` reads the object definition before it
+fetches the rows, so it now passes the definition's fields to the grid as `objectFields`
+with them, and the grid has no window at all. Only the grid view is handed it. When the
+definition could not be read, nothing is passed and the grid withholds on its own.
diff --git a/.changeset/10657-types-withheld-doc.md b/.changeset/10657-types-withheld-doc.md
new file mode 100644
index 0000000000..f79b9a38a1
--- /dev/null
+++ b/.changeset/10657-types-withheld-doc.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/types': patch
+---
+
+`TableColumn.masked`'s documentation describes withheld columns: a column whose type is not known yet is drawn as the mask and carries the flag (objectui#10657, which folded objectui#10706).
+
+The JSDoc said that the related list and `object-data-table` still draw a `password` /
+`secret` field as text while their object definition is loading or after its read
+failed, and that `object-grid` draws an untyped column's raw value on its host-fetched
+path until its object schema arrives. Neither holds any more: each of the three producers
+now draws such a column as the mask and sets the flag on it, and `object-grid` takes the
+field types from a host that has them (`objectFields`). The JSDoc is rewritten to say so.
+The pending objectui#10657 `@object-ui/types` changeset says these docs state what that
+window does not cover; once this change ships with it, they state what it withholds
+instead. No type or schema shape changes.
diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx
index 44ae90b415..05c3ca6f37 100644
--- a/content/docs/components/complex/data-table.mdx
+++ b/content/docs/components/complex/data-table.mdx
@@ -170,23 +170,26 @@ so a column that shows a `secret` field as `text` keeps the flag once the object
types are known.
The related list and `object-data-table` fail closed: while the object definition is
-still loading, or after its read failed, they set the flag on every column. What they
-do not cover: in that same window, their cells draw a field the object declares as
-`password` / `secret` as text, because the mask comes from that declaration
-(objectui#10657).
-
-Once the object schema has loaded, `object-grid` also leaves every masked field of the
-grid's object out of its own client export (CSV and JSON), draws those fields through
-their `cell` on its mobile card, and refuses them as grouping keys (the entry is
-ignored with a console warning). What `object-grid` does not cover: on the
-host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView` do), the
-grid's guards and the cell's own mask depend on the object schema, which the grid
-fetches after first paint, so until it arrives, and for good if that read fails, an
-untyped view column over a `password` / `secret` field draws and hands out the raw
-value (objectui#10657, which folded objectui#10706). The server-streamed export
-(`exportDownload`) sends the masked columns as before and relies on the server's
-masking, and the client JSON export writes an expanded lookup record whole, so a
-credential field of the related object is not pruned.
+still loading, or after its read failed, they set the flag on every column.
+
+A column whose type is not known yet is **withheld** (objectui#10657, which folded
+objectui#10706). While the object's field types are loading, or after their read
+failed, `object-grid`, the related list and `object-data-table` draw such a column as
+the mask, never as text, and set the flag on it; it stays withheld when the read
+failed. In `object-grid` and `object-data-table` that is a column with no `type` of its
+own. The related list draws its cells from the object's types only, so there it is
+every column without a `cell` of its own. A host that already holds the object's fields
+hands them to `object-grid` as its `objectFields` prop, as the list view does, and then
+there is nothing to withhold.
+
+`object-grid` also leaves every masked or withheld field of the grid's object out of its
+own client export (CSV and JSON), draws those fields through their `cell` on its mobile
+card, and refuses them as grouping keys (a masked entry is ignored with a console
+warning; a withheld one groups again once the types are known). What `object-grid` does
+not cover: the server-streamed export (`exportDownload`) sends the masked columns as
+before and relies on the server's masking, and the client JSON export writes an
+expanded lookup record whole, so a credential field of the related object is not
+pruned.
## Examples
diff --git a/packages/fields/README.md b/packages/fields/README.md
index 6650ce9492..126fa252c4 100644
--- a/packages/fields/README.md
+++ b/packages/fields/README.md
@@ -153,6 +153,21 @@ withholding it. Your own component under an undeclared type answers `false`.
Spellings are matched raw, the way `getCellRenderer` looks them up:
`field:password` renders in the clear and is not masked.
+The mask itself is exported as `MaskedCellRenderer` (objectui#10657). A table
+that cannot type a column yet, because its object's field types are still
+loading or their read failed, draws that column WITHHELD with it rather than as
+text: `object-grid`, the related list and `object-data-table` do. It is the
+shipped mask, not whatever is registered for `password` at the time, and its
+`field` prop is optional because it reads only whether a value is present.
+`registerFieldRenderer('api_token', MaskedCellRenderer)` masks a type the same
+way the `getCellRenderer('password')` form above does.
+
+```tsx
+import { MaskedCellRenderer } from '@object-ui/fields';
+
+const withheldCell = (value: unknown) => ;
+```
+
### File uploads in line-item grids
`GridField` (the master-detail line-items grid) supports `type: 'file'` columns:
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index b899f552bd..b1b5b302ce 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -3362,8 +3362,22 @@ function RepeaterCellRenderer({ value }: CellRendererProps): React.ReactElement
* states: `EmptyValue` holds a hook, and an inline arrow in the table below
* is a new component type on every resolution, so it would tear that hook down
* per render.
+ *
+ * Exported (objectui#10657) for the cell a producer cannot type yet. While an
+ * object-bound table is still waiting for its object's field types, or after
+ * that read failed, a column with no type of its own could be a credential,
+ * so `ObjectGrid`, `RelatedList` and `ObjectDataTable` draw it WITHHELD: as
+ * this mask, never as text. It is this component and not the live registry
+ * entry for `password`, so a host override of a masked type cannot change what
+ * a withheld cell draws. Registering it for a type
+ * (`registerFieldRenderer('api_token', MaskedCellRenderer)`) masks that type,
+ * as {@link isMaskedFieldType} describes. `field` is optional: the mask reads
+ * only whether the value is present, so a withheld cell has no field metadata
+ * to invent.
*/
-function MaskedCellRenderer({ value }: CellRendererProps): React.ReactElement {
+export function MaskedCellRenderer({
+ value,
+}: Omit & { field?: CellRendererProps['field'] }): React.ReactElement {
if (isEmptyValue(coerceToSafeValue(value))) return ;
return ••••••;
}
diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md
index c19def8107..e79d724e95 100644
--- a/packages/plugin-dashboard/README.md
+++ b/packages/plugin-dashboard/README.md
@@ -624,7 +624,10 @@ an author override does not win: `type: 'text'` over a `secret` field keeps the
flag, though the cell then draws the value as text. Rows handed in as `data`
or through `bind` can be drawn before the object schema arrives, and a failed
schema read never delivers one; in that window the widget flags every column,
-and a masked field is still drawn as text.
+and a column with no `type` of its own draws as the mask, never as text, until
+the schema arrives, or for good when the read failed (objectui#10657). A column
+that authors its `type` draws from it meanwhile. The record drawer a row opens
+(record drill-down) draws every value as the mask in that window too.
```jsonc
{
diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx
index 8dcddfd033..6c9560c07c 100644
--- a/packages/plugin-dashboard/src/ObjectDataTable.tsx
+++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx
@@ -17,7 +17,7 @@ import {
import type { ObjectDataTableSchema, TableColumn } from '@object-ui/types';
import { normalizeTableColumnType } from '@object-ui/types';
import { Skeleton, RefreshIndicator, cn } from '@object-ui/components';
-import { isMaskedFieldType } from '@object-ui/fields';
+import { isMaskedFieldType, MaskedCellRenderer } from '@object-ui/fields';
import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
import { resolveFilterPlaceholders, humanizeFieldKey } from './utils';
@@ -413,6 +413,15 @@ export type AuthoredColumnOverrides =
*/
const EMPTY_ROWS = Object.freeze([]) as unknown as any[];
+/**
+ * The cell a WITHHELD column draws (objectui#10657): the mask, never the
+ * value. See `enrich` for when a column is withheld. Module-level, so the
+ * column keeps one `cell` identity across renders.
+ */
+function withheldCell(value: unknown): React.ReactNode {
+ return React.createElement(MaskedCellRenderer, { value });
+}
+
/**
* Normalize columns to support both string[] shorthand and object[] formats.
*
@@ -948,6 +957,21 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo
if (typeof col.cell === 'function') return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, ...maskedStamp };
+ // ⭐ WITHHELD (objectui#10657, the objectui#10706 class at this
+ // producer). The flag above withholds; it does not DRAW. `cell` draws
+ // from `fieldMeta.type`, which is the authored `type` or the object's,
+ // so while the object's types are unknown a column that authors none has
+ // no type, and the cell would draw its value as text: a `password` /
+ // `secret` field in the clear, and for good when the read failed. Such a
+ // column draws the mask instead until the definition lands, and keeps
+ // drawing it when the read failed (fail closed, never falling back to
+ // text). A column that authors its own `type` draws from it, as it does
+ // once the definition is in hand: `password` masks, `text` draws the
+ // text it was told to.
+ if (objectTypesPending && !authored.type) {
+ return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell: withheldCell, ...maskedStamp };
+ }
+
// Tenant-default currency backstops a currency column with no explicit code.
const cell = (value: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale);
return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell, ...maskedStamp };
@@ -1129,6 +1153,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo
title={recordTitle}
target={drillDown?.target === 'dialog' ? 'dialog' : 'drawer'}
onClose={() => setDrillRecord(null)}
+ objectTypesPending={objectTypesPending}
/>
)}
diff --git a/packages/plugin-dashboard/src/RecordDetailDrawer.tsx b/packages/plugin-dashboard/src/RecordDetailDrawer.tsx
index 2ead771df5..df9fe69599 100644
--- a/packages/plugin-dashboard/src/RecordDetailDrawer.tsx
+++ b/packages/plugin-dashboard/src/RecordDetailDrawer.tsx
@@ -27,6 +27,7 @@ import {
EmptyValue,
} from '@object-ui/components';
import { useSafeFieldLabel, useLocalization, useDisplayLocale } from '@object-ui/i18n';
+import { MaskedCellRenderer } from '@object-ui/fields';
import {
indexObjectFields,
buildFieldMeta,
@@ -55,6 +56,14 @@ export interface RecordDetailDrawerProps {
target?: 'drawer' | 'dialog';
/** Called when the drawer requests to close. */
onClose: () => void;
+ /**
+ * Are the object's field types still unknown (objectui#10657)? The owning
+ * table's reading: its definition is in flight, or its read failed. A value
+ * is drawn from its field type, so with none in hand every value here is
+ * drawn WITHHELD, as the mask — a `password` / `secret` field would
+ * otherwise be printed as text — exactly as the table's own cells are.
+ */
+ objectTypesPending?: boolean;
}
/** Candidate fields, in priority order, used to title the drawer. */
@@ -79,6 +88,7 @@ export const RecordDetailDrawer: React.FC = ({
title,
target = 'drawer',
onClose,
+ objectTypesPending = false,
}) => {
const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel();
const { currency: tenantCurrency } = useLocalization();
@@ -123,11 +133,15 @@ export const RecordDetailDrawer: React.FC = ({
return {
key,
label,
- node: renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale),
- numeric: isNumericFieldMeta(fieldMeta),
+ // Withheld while the field types are unknown (objectui#10657): the
+ // mask, never the value, and so for good after a failed read.
+ node: objectTypesPending
+ ?
+ : renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale),
+ numeric: !objectTypesPending && isNumericFieldMeta(fieldMeta),
};
});
- }, [record, objectSchema, objectName, fields, fieldLabel, fieldOptionLabel, tenantCurrency, displayLocale]);
+ }, [record, objectSchema, objectName, fields, fieldLabel, fieldOptionLabel, tenantCurrency, displayLocale, objectTypesPending]);
if (!record) return null;
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx
index a1959e718e..c120ac9fe2 100644
--- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx
@@ -163,7 +163,10 @@ describe('object-data-table reads once per mount, expanded, and keys on its expa
vi.spyOn(console, 'error').mockImplementation(() => {});
const ds = makeDataSource({ schema: 'reject' });
render();
- await waitFor(() => expect(screen.getByText('Acme')).toBeInTheDocument());
+ // objectui#10657 PR 2: with no definition the untyped column is WITHHELD
+ // (drawn as the mask, never as text), so the shown row is read by its mask.
+ await waitFor(() => expect(document.querySelector('tbody')?.textContent ?? '').toContain('••••••'));
+ expect(screen.queryByText('Acme')).toBeNull();
await settle();
expect(expands(ds)).toEqual([null]);
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx
index b1dea64f07..74d6e6ee69 100644
--- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx
@@ -243,7 +243,11 @@ describe('ObjectDataTable — a masked column is withheld on every table path (o
});
describe('ObjectDataTable — fail closed while the object types are unknown (objectui#10657)', () => {
- const inline = { data: ROWS, columns: [{ field: 'name', label: 'Name' }, { field: 'api_key', label: 'API Key' }] };
+ // `Name` AUTHORS `type: 'text'`, so it draws inside the window: an untyped
+ // column there is withheld from the DRAW too (objectui#10657 PR 2, pinned in
+ // `ObjectDataTable.maskedFirstPaint-10657`). This file pins the FLAG, which
+ // still stamps every column, typed or not, until the types are known.
+ const inline = { data: ROWS, columns: [{ field: 'name', label: 'Name', type: 'text' }, { field: 'api_key', label: 'API Key' }] };
it('inline rows paint before the definition lands: no cell copies until it does', async () => {
const ds = makeDataSource('held');
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx
new file mode 100644
index 0000000000..c66d247fb0
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx
@@ -0,0 +1,193 @@
+/**
+ * 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.
+ */
+
+/**
+ * `ObjectDataTable` WITHHOLDS an untyped column from the draw while the bound
+ * object's field types are unknown (objectui#10657 — the objectui#10706 class
+ * at this producer).
+ *
+ * ## The defect
+ *
+ * A cell here draws from `fieldMeta.type`: the column's authored `type`, or
+ * the object's. Bound and inline rows paint before the object definition
+ * lands, and a failed read settles with none, so in that window a column that
+ * authors no type drew its value as text: a `password` field in the clear, for
+ * good when the read failed. PR 1 of this card stamped every column `masked`
+ * in that window; the stamp withholds copy, tooltip, export, search and sort,
+ * but it does not draw.
+ *
+ * ## The contract pinned here
+ *
+ * - HELD and REJECTED: an untyped column draws the mask; the raw value is
+ * nowhere in the DOM, text or attribute. Rows the widget FETCHED after a
+ * failed read are withheld the same way.
+ * - SETTLED: the withholding lifts — the declared `text` field draws its value
+ * — and the declared `password` field keeps the mask.
+ * - THE RECORD DRAWER (record drill-down) draws a clicked row's fields from the
+ * same unknown types, so after a failed read it withholds every value too.
+ *
+ * ## Controls
+ *
+ * Each mount carries `Name`, a column that AUTHORS `type: 'text'`: its type
+ * does not wait on the definition, so it draws its value in every arm. The
+ * auto-derived arm (no authored columns, so no typed control) is read against
+ * its headers and its drawn masks instead.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, act, cleanup, waitFor, fireEvent, screen, within } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+// Registers `object-data-table` (and, through its imports, `data-table`).
+import '../index';
+
+const MASK = '••••••';
+const RAW_KEY = 'RAW-ZULU-10657';
+const RAW_NOTE = 'NOTE-TANGO-10657';
+
+const FIELDS = {
+ name: { type: 'text', label: 'Name' },
+ api_key: { type: 'password', label: 'API Key' },
+ note: { type: 'text', label: 'Note' },
+};
+
+const ROWS = [{ id: 'v1', name: 'Ada', api_key: RAW_KEY, note: RAW_NOTE }];
+
+/** `Name` authors its type (the control); `API Key` and `Note` author none. */
+const COLUMNS = [
+ { field: 'name', label: 'Name', type: 'text' },
+ { field: 'api_key', label: 'API Key' },
+ { field: 'note', label: 'Note' },
+];
+
+type SchemaMode = 'held' | 'rejected';
+
+function makeDataSource(mode: SchemaMode) {
+ let release!: () => void;
+ const held = new Promise((resolve) => { release = resolve; });
+ return {
+ release: () => release(),
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: vi.fn(async () => {
+ if (mode === 'held') await held;
+ if (mode === 'rejected') throw new Error('metadata unavailable');
+ return { name: 'vault', fields: FIELDS };
+ }),
+ };
+}
+
+function mount(ds: ReturnType, widget: Record) {
+ const schema = { type: 'object-data-table', objectName: 'vault', ...widget };
+ return render(
+
+
+ ,
+ );
+}
+
+const headerTexts = () =>
+ Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim());
+
+/** The body cell under the header `label`, in the first row. */
+function cellUnder(label: string): HTMLElement {
+ const index = headerTexts().indexOf(label);
+ expect(index, `CONTROL: a "${label}" header rendered (headers: ${headerTexts().join(' | ')})`).toBeGreaterThanOrEqual(0);
+ const cell = document.querySelectorAll('tbody tr')[0]?.children[index] as HTMLElement | undefined;
+ expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD');
+ return cell!;
+}
+
+beforeEach(() => {
+ // Best-effort metadata probes are not what these cases are about.
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' })));
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+/** CONTROL, then the withheld reading. */
+async function expectWithheld() {
+ await waitFor(() => expect(cellUnder('Name').textContent, 'CONTROL: the typed text column draws').toContain('Ada'));
+ expect(cellUnder('API Key').textContent, 'the untyped password column draws the mask').toContain(MASK);
+ expect(cellUnder('Note').textContent, 'the untyped text column is withheld too: its type is unknown').toContain(MASK);
+ // Text OR attribute: nowhere in the DOM.
+ expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY);
+ expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE);
+}
+
+describe('ObjectDataTable — an untyped column is withheld while the object types are unknown (objectui#10657)', () => {
+ it('HELD (inline rows): the untyped columns draw the mask; the typed text column draws', async () => {
+ const ds = makeDataSource('held');
+ mount(ds, { data: ROWS, columns: COLUMNS });
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
+ await expectWithheld();
+ });
+
+ it('REJECTED (inline rows): a failed read keeps them withheld — fail closed, never back to text', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ds = makeDataSource('rejected');
+ mount(ds, { data: ROWS, columns: COLUMNS });
+ await waitFor(() => expect(error).toHaveBeenCalled());
+ await act(async () => {});
+ await expectWithheld();
+ });
+
+ it('REJECTED (fetched rows): rows fetched after the failed read are withheld the same way', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ds = makeDataSource('rejected');
+ mount(ds, { columns: COLUMNS });
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
+ expect(error, 'CONTROL: the definition read failed before the rows were fetched').toHaveBeenCalled();
+ await expectWithheld();
+ });
+
+ it('HELD (auto-derived columns): every derived column is withheld', async () => {
+ const ds = makeDataSource('held');
+ mount(ds, { data: ROWS });
+ // CONTROL — the derived headers rendered, and the row drew (as masks).
+ await waitFor(() => expect(headerTexts()).toEqual(expect.arrayContaining(['Name', 'Api Key', 'Note'])));
+ await waitFor(() => expect(cellUnder('Api Key').textContent).toContain(MASK));
+ expect(cellUnder('Name').textContent).toContain(MASK);
+ expect(document.body.innerHTML).not.toContain(RAW_KEY);
+ expect(document.body.innerHTML).not.toContain(RAW_NOTE);
+ });
+
+ it('SETTLED: the declared text field draws once the definition lands; the declared password keeps the mask', async () => {
+ const ds = makeDataSource('held');
+ mount(ds, { data: ROWS, columns: COLUMNS });
+ await expectWithheld();
+ await act(async () => { ds.release(); });
+ // CONTROL — the withholding lifts for a field the object declares `text`.
+ await waitFor(() => expect(cellUnder('Note').textContent).toContain(RAW_NOTE));
+ expect(cellUnder('API Key').textContent, 'the declared password is masked').toContain(MASK);
+ expect(document.body.innerHTML).not.toContain(RAW_KEY);
+ });
+
+ it('REJECTED: the record drawer a row opens draws every value withheld', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ds = makeDataSource('rejected');
+ mount(ds, { data: ROWS, columns: COLUMNS, drillDown: { enabled: true } });
+ await waitFor(() => expect(error).toHaveBeenCalled());
+ await expectWithheld();
+
+ fireEvent.click(cellUnder('Name'));
+ const body = await screen.findByTestId('record-detail-body');
+ // CONTROL — the drawer rendered this record's fields, not an empty shell.
+ expect(within(body).getByText('Api Key')).toBeInTheDocument();
+ expect(body.textContent, 'the drawer draws the values as the mask').toContain(MASK);
+ expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY);
+ expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE);
+ });
+});
diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md
index 4b97251c25..ada79c382b 100644
--- a/packages/plugin-detail/README.md
+++ b/packages/plugin-detail/README.md
@@ -460,8 +460,10 @@ it by its value. The rule is `isMaskedFieldType()` from `@object-ui/fields`,
read over the column's authored `type` and the object's field type, so an
authored `type: 'text'` over a `secret` field keeps the flag. While the object
definition is still loading, or after its read failed, the list cannot tell
-which columns are masked and flags every one; in that window a masked field is
-still drawn as text. Not covered: the opt-in filter box matches against every
+which columns are masked, so it flags every one and draws every cell it would
+draw from that definition as the mask, never as text; it keeps doing so when
+the read failed (objectui#10657). A column with a `cell` of your own draws what
+your `cell` returns. Not covered: the opt-in filter box matches against every
field of a row, and the `list` card's sort buttons sort by any column, masked
ones included.
diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx
index f040a1f558..5b238d3063 100644
--- a/packages/plugin-detail/src/RelatedList.tsx
+++ b/packages/plugin-detail/src/RelatedList.tsx
@@ -41,7 +41,7 @@ import {
import type { LucideIcon } from 'lucide-react';
import type { DataSource, FieldMetadata } from '@object-ui/types';
import type { ViewFilterRule } from '@objectstack/spec/ui';
-import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns } from '@object-ui/fields';
+import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer } from '@object-ui/fields';
import {
buildExpandFields,
collectPredicateFieldRefs,
@@ -349,6 +349,15 @@ type FieldReadPolicy = ReturnType;
*/
const MOBILE_GALLERY_COVER_FIELD = 'image';
+/**
+ * The cell a WITHHELD column draws (objectui#10657): the mask, never the
+ * value. See `tableColumns` for when a column is withheld. Module-level, so
+ * the column keeps one `cell` identity across renders.
+ */
+function withheldCell(value: unknown): React.ReactElement {
+ return React.createElement(MaskedCellRenderer, { value });
+}
+
/**
* The key this component DRAWS a column through: the table library's
* `accessorKey` first, then the shared metadata reader. Every column gate below
@@ -1979,6 +1988,19 @@ export const RelatedList: React.FC = ({
* one, so every column is stamped. A column the loaded definition does not
* declare is judged on its authored `type` alone.
*
+ * WITHHELD, not only stamped (objectui#10657, the objectui#10706 class at
+ * this producer): in that same window the columns' cells cannot draw a mask
+ * either. This list draws a cell from the OBJECT's field type
+ * (`makeCell`), never from the column's authored `type`, so with no
+ * definition in hand a column has no cell and the table would draw its value
+ * as text — a `password` / `secret` field in the clear, and for good when
+ * the read failed. So every column without a `cell` of its own draws the
+ * mask ({@link withheldCell}) until the definition lands, and keeps drawing
+ * it when the read failed: fail closed, never falling back to text. A
+ * `cell` the AUTHOR supplied draws what the author chose (none is attached
+ * here while the definition is missing, so any `cell` in that window is
+ * theirs).
+ *
* With nothing to stamp, the list is handed on BY REFERENCE, as
* `sortableColumns` hands it on (the data-table re-seed, objectui#4618).
*/
@@ -1988,7 +2010,9 @@ export const RelatedList: React.FC = ({
const field = col.accessorKey || columnIdentity(col);
const fieldDef = field ? objectSchema?.fields?.[field] : undefined;
const masked = objectTypesPending || isMaskedDetailFieldType(col.type, fieldDef?.type);
- return masked && col.masked !== true ? { ...col, masked: true } : col;
+ const withheld = objectTypesPending && typeof col.cell !== 'function';
+ if (!withheld) return masked && col.masked !== true ? { ...col, masked: true } : col;
+ return { ...col, masked: true, cell: withheldCell };
});
return stamped.every((col, i) => col === sortableColumns[i]) ? sortableColumns : stamped;
}, [sortableColumns, objectSchema, objectTypesPending]);
diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx
index 69c355c7b1..89592862bf 100644
--- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx
+++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx
@@ -306,8 +306,10 @@ describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kep
,
);
- const cells = await paintedCells();
- expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter']));
- expect(cells).not.toContain('90000');
+ // objectui#10657 PR 2: the first cells paint WITHHELD (as the mask) while
+ // the definition is in flight, so wait for the settled draw, not the first
+ // cell.
+ await waitFor(() => expect(cellTexts()).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])));
+ expect(cellTexts()).not.toContain('90000');
});
});
diff --git a/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx
index 5736a71ba4..631e8fa0c0 100644
--- a/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx
+++ b/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx
@@ -135,9 +135,25 @@ const drawnNames = () =>
const payloads = () => writeText.mock.calls.map((call) => call[0]);
-/** Wait until the object definition has settled and the mask is drawn. */
+/**
+ * Wait until the object definition has settled and the mask is drawn.
+ *
+ * The drawn NAME is what says "settled": since objectui#10657 PR 2 every cell
+ * is withheld (drawn as the mask) while the definition is unknown, so a mask
+ * alone no longer tells the two windows apart.
+ */
async function settledWithMask() {
+ await waitFor(() => {
+ const body = document.querySelector('tbody')?.textContent ?? '';
+ expect(body).toContain(MASK);
+ expect(body).toContain('Ada');
+ });
+}
+
+/** Wait until the rows have painted WITHHELD: masks, and no name drawn yet. */
+async function withheldWithMask() {
await waitFor(() => expect(document.querySelector('tbody')?.textContent ?? '').toContain(MASK));
+ expect(document.querySelector('tbody')?.textContent ?? '').not.toContain('Ada');
}
const SHAPES: Array<{ shape: string; props: Record }> = [
@@ -224,8 +240,11 @@ describe('RelatedList — fail closed while the object types are unknown (object
it('while the definition is in flight no cell copies; once it lands the text cell does', async () => {
const ds = makeDataSource('held');
mount(ds, { columns: ['name', 'api_key'] });
- await waitFor(() => expect(screen.getByText('Ada')).toBeInTheDocument());
- const nameCell = () => screen.getByText('Ada').closest('td') as HTMLElement;
+ // objectui#10657 PR 2: in this window every cell is also WITHHELD from the
+ // draw (the mask, never the value; `RelatedList.maskedFirstPaint-10657`),
+ // so the row is found by position, not by the name it no longer prints.
+ await withheldWithMask();
+ const nameCell = () => document.querySelectorAll('tbody tr')[0]!.children[0] as HTMLElement;
const keyCell = () => document.querySelectorAll('tbody tr')[0]!.children[1] as HTMLElement;
fireEvent.keyDown(nameCell(), { key: 'c', ctrlKey: true });
@@ -244,7 +263,8 @@ describe('RelatedList — fail closed while the object types are unknown (object
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mount(ds, { columns: ['name', 'api_key'] });
await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
- await waitFor(() => expect(screen.getByText('Ada')).toBeInTheDocument());
+ // Withheld from the draw as well (objectui#10657 PR 2): wait for the mask.
+ await withheldWithMask();
await act(async () => {});
const row = document.querySelectorAll('tbody tr')[0]!;
fireEvent.keyDown(row.children[0] as HTMLElement, { key: 'c', ctrlKey: true });
diff --git a/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx
new file mode 100644
index 0000000000..a0c827be73
--- /dev/null
+++ b/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx
@@ -0,0 +1,157 @@
+/**
+ * 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.
+ */
+
+/**
+ * `RelatedList` WITHHOLDS every cell it would draw from the object definition
+ * while that definition is unknown (objectui#10657 — the objectui#10706 class
+ * at this producer).
+ *
+ * ## The defect
+ *
+ * This list draws a cell from the OBJECT's field type (`makeCell`), never from
+ * a column's authored `type`. While `getObjectSchema` was pending, and for good
+ * after it rejected, no column had a cell, so the table drew every value as
+ * text: a `password` field in the clear. PR 1 of this card stamped every
+ * column `masked` in that window (no copy, tooltip, sort), but the stamp
+ * withholds, it does not draw.
+ *
+ * ## The contract pinned here
+ *
+ * - HELD and REJECTED: the declared `password` column (and every other column
+ * with no cell of its own) draws the mask; the raw value is nowhere in the
+ * DOM, text or attribute.
+ * - SETTLED: the withholding lifts — the declared `text` field draws its value
+ * — and the declared `password` field keeps the mask.
+ *
+ * ## Controls
+ *
+ * Each mount carries `Label`, a column whose `cell` the AUTHOR supplied: the
+ * list attaches nothing to it, so it draws its value in every arm, which makes
+ * the absence of the raw credential a reading and not a table that drew
+ * nothing. Both authored column shapes are driven (bare strings and objects);
+ * the auto-derived walk derives nothing without a definition, so it has no
+ * window to pin.
+ */
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { render, waitFor, cleanup, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import * as React from 'react';
+
+// The real data-table (and its cell renderers) must be registered: this pin
+// reads what they DRAW.
+import '@object-ui/components';
+import { RelatedList } from '../RelatedList';
+
+const MASK = '••••••';
+const RAW_KEY = 'RAW-ZULU-10657';
+const CONTROL_VALUE = 'LABEL-ADA-10657';
+
+const FIELDS = {
+ name: { type: 'text', label: 'Name' },
+ api_key: { type: 'password', label: 'API Key' },
+};
+
+const ROWS = [{ id: 'v1', name: 'Ada', label: CONTROL_VALUE, api_key: RAW_KEY }];
+
+/** The author's own cell: this list attaches nothing to it. */
+const LABEL_COLUMN = { field: 'label', label: 'Label', cell: (value: unknown) => String(value) };
+
+type SchemaMode = 'held' | 'rejected';
+
+function makeDataSource(mode: SchemaMode) {
+ let release!: () => void;
+ const held = new Promise((resolve) => { release = resolve; });
+ return {
+ release: () => release(),
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
+ getObjectSchema: vi.fn(async () => {
+ if (mode === 'held') await held;
+ if (mode === 'rejected') throw new Error('metadata unavailable');
+ return { name: 'vault', fields: FIELDS };
+ }),
+ };
+}
+
+const SHAPES: Array<{ shape: string; columns: unknown[] }> = [
+ { shape: 'bare-string columns', columns: ['name', 'api_key', LABEL_COLUMN] },
+ {
+ shape: 'authored object columns',
+ columns: [{ field: 'name', label: 'Name' }, { field: 'api_key', label: 'API Key' }, LABEL_COLUMN],
+ },
+];
+
+function mount(ds: ReturnType, columns: unknown[]) {
+ return render(
+ ,
+ );
+}
+
+/** The body cell under the column at `index` in the first row. */
+const cellAt = (index: number) => document.querySelectorAll('tbody tr')[0]!.children[index] as HTMLElement;
+
+beforeEach(() => {
+ // Desktop: below 768px this list renders an `object-gallery`, not a table.
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+/** CONTROL, then the withheld reading: the author's cell drew, the rest are masks. */
+async function expectWithheld() {
+ await waitFor(() => expect(cellAt(2)?.textContent, 'CONTROL: the author-supplied cell draws its value').toBe(CONTROL_VALUE));
+ expect(cellAt(0).textContent, 'the `name` column is withheld too: its type is unknown').toContain(MASK);
+ expect(cellAt(1).textContent, 'the `password` column draws the mask').toContain(MASK);
+ // Text OR attribute: nowhere in the DOM.
+ expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY);
+}
+
+describe('RelatedList — a cell is withheld while the object definition is unknown (objectui#10657)', () => {
+ for (const { shape, columns } of SHAPES) {
+ describe(shape, () => {
+ it('HELD: every definition-drawn cell is the mask; the author cell draws', async () => {
+ const ds = makeDataSource('held');
+ mount(ds, columns);
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
+ await expectWithheld();
+ });
+
+ it('REJECTED: a failed read keeps them withheld — fail closed, never back to text', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ds = makeDataSource('rejected');
+ mount(ds, columns);
+ // `useSettledSchema` reports the failed read: the settle has happened.
+ await waitFor(() => expect(error).toHaveBeenCalled());
+ await act(async () => {});
+ await expectWithheld();
+ });
+
+ it('SETTLED: the declared text field draws; the declared password keeps the mask', async () => {
+ const ds = makeDataSource('held');
+ mount(ds, columns);
+ await expectWithheld();
+ await act(async () => { ds.release(); });
+ // CONTROL — the withholding lifts for a field the object declares `text`.
+ await waitFor(() => expect(cellAt(0).textContent).toBe('Ada'));
+ expect(cellAt(1).textContent, 'the declared password is masked').toContain(MASK);
+ expect(cellAt(2).textContent).toBe(CONTROL_VALUE);
+ expect(document.body.innerHTML).not.toContain(RAW_KEY);
+ });
+ });
+ }
+});
diff --git a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx
index 6b8ad591a4..5e0ab3c460 100644
--- a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx
+++ b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx
@@ -455,7 +455,13 @@ describe('RelatedList — the fetch waits for the SETTLED child definition, and
}),
};
renderList(ds, 'contact', 'account');
- expect(await screen.findByText('Alice')).toBeInTheDocument();
+ // objectui#10657 PR 2: after a failed definition read every cell is
+ // WITHHELD (drawn as the mask), so the shown rows are counted, not read
+ // by name: the parent-scoped query returns the one matching row.
+ await waitFor(() => {
+ expect(document.querySelector('tbody')?.textContent ?? '').toContain('••••••');
+ expect(document.querySelectorAll('tbody tr')).toHaveLength(SINGLE_MATCH_COUNT);
+ });
expect(screen.queryByText('Bob')).toBeNull();
await new Promise((r) => setTimeout(r, 10));
expect(scopedCalls(ds as any, 'contact')).toEqual([{ ...PROJECTION, $filter: { account: PARENT_ID } }]);
diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md
index 11c961b81e..d2dd4e8b11 100644
--- a/packages/plugin-grid/README.md
+++ b/packages/plugin-grid/README.md
@@ -393,14 +393,22 @@ The table also leaves such a column out of its client-side search, disables its
and sizes it from its header rather than its values (objectui#10657, which folded
objectui#10658).
+Before the object schema has loaded — on the host-fetched path (rows handed down as
+`data`), the rows paint first — an untyped view column is **withheld**: it draws the
+mask, never its value as text, and every path above treats it as masked (a grouping
+entry on it is ignored, without a warning, until the schema is in). It stays withheld
+when the schema read fails. A column that authors its own `type` draws from it
+meanwhile. The record panel a row opens under overlay navigation draws every value as
+the mask in that window too.
+
+A host that already holds the object's definition passes its `fields` to the grid as
+the `objectFields` prop, beside the `data` it fetched, and the grid answers from them
+until its own read lands, so there is no such window; `ListView` does this
+(objectui#10657, which folded objectui#10706). `objectFields` is a runtime prop only:
+an `objectFields` key authored in the schema never reaches the grid.
+
Not covered:
-- On the host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView`
- do), the grid's guards and the cell's own mask depend on the object schema, which
- the grid fetches after first paint. Until it arrives, and for good if that read fails
- (the grid swallows the failure and keeps its heuristic column types), an untyped view
- column over a `password` / `secret` field draws and hands out the raw value
- (objectui#10657, which folded objectui#10706).
- The server-streamed export (`exportDownload`) sends the masked columns as before
and relies on the server's masking.
- The client JSON export writes an expanded lookup record whole, so a credential
diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx
index 5a641f7282..64aa5b9781 100644
--- a/packages/plugin-grid/src/ObjectGrid.tsx
+++ b/packages/plugin-grid/src/ObjectGrid.tsx
@@ -33,8 +33,8 @@ import { createSafeTranslation } from '@object-ui/i18n';
// what dropped a `format`-hinted column's renderer, and one shared owner is
// what stops a seventh site picking a convention of its own.
import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution';
-import { isMaskedGridColumn } from './maskedColumn';
-import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields';
+import { isMaskedGridColumn, isWithheldGridColumn } from './maskedColumn';
+import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer } from '@object-ui/fields';
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
// Two resolvers, two vocabularies — the repo spells the distinction into the
// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own
@@ -613,6 +613,31 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr
schema: ObjectGridSchema;
dataSource?: DataSource;
className?: string;
+ /**
+ * [objectui#10657] The object's field catalogue (`fields` of the definition
+ * `getObjectSchema(objectName)` returns), handed down by a HOST that has
+ * already read it. `ListView` passes the definition it fetched before its
+ * rows; the grid answers every field-type question from it until its own
+ * read of the definition lands, and from its own read after that.
+ *
+ * ## Why a host hands it down
+ *
+ * A host that fetches the rows hands them down as `data`, so they paint
+ * before the grid's own schema read settles. Until then an untyped view
+ * column over a `password` / `secret` field has no type, and the grid can
+ * only withhold it (drawn as the mask, never as text). With the catalogue in
+ * hand there is no such window: the column draws, and is flagged, from its
+ * declared type at first paint.
+ *
+ * ## A RUNTIME prop, never authored metadata
+ *
+ * The name is the host channel decision batch #70 ruled for the object's
+ * field catalogue (objectui#7742): `SchemaRenderer` strips an AUTHORED
+ * `objectFields` on every spelling (objectui#8818), so only a host's React
+ * prop can reach this slot, and no view author can supply a catalogue that
+ * types a credential field as text.
+ */
+ objectFields?: Readonly>;
/**
* [objectui#8674] Narrow ONE row's generic Edit / Delete entries — the layer
* that lets a host withhold an operation the record itself cannot accept.
@@ -1382,6 +1407,12 @@ function describeNonPositivePageSize(
);
}
+/**
+ * The grouping-refusal signature when no entry is refused (objectui#10583,
+ * objectui#10657): the authored grouping config is then handed on as it is.
+ */
+const NO_GROUPING_REFUSAL = JSON.stringify({ refused: [], warned: [] });
+
export const ObjectGrid: React.FC = ({
schema,
dataSource,
@@ -1413,6 +1444,7 @@ export const ObjectGrid: React.FC = ({
onSearchChange: hostOnSearchChange,
findParams: hostFindParams,
onColumnStateChange,
+ objectFields: hostObjectFields,
}) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
@@ -1605,6 +1637,32 @@ export const ObjectGrid: React.FC = ({
// (`ViewDataSchema` declares it required). Kept because this name gates the
// permission verdicts below — see the note at `inlineEditable`.
const objectName = resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName;
+ /**
+ * The field catalogue this grid answers field-type questions from
+ * (objectui#10657): its own object definition once that read has landed,
+ * the catalogue a host handed down (`objectFields`) until then.
+ *
+ * ⚠️ Only the field reads that decide how a column DRAWS and whether it is
+ * MASKED go through this — the column builders, the masked flag, the client
+ * export, the grouping, the mobile card. Everything that needs the whole
+ * definition (actions, `highlightFields`, the default-column policy) still
+ * waits for `objectSchema`, exactly as before.
+ */
+ const objectFields: Record | undefined =
+ objectSchema?.fields ?? hostObjectFields ?? undefined;
+ /**
+ * Are this grid's OBJECT field types still unknown (objectui#10657, which
+ * folded objectui#10706)? True while an object definition is expected (an
+ * object to ask about, and a data source that can answer `getObjectSchema`)
+ * and no catalogue is in hand: the read is in flight, or it failed, which
+ * the inline path swallows as non-fatal. Rows a host hands down paint inside
+ * that window, so every untyped column is WITHHELD until it closes, and for
+ * good if it never does (`isWithheldGridColumn`). With no `getObjectSchema`
+ * there is nothing to wait for, and the authored column types are all this
+ * grid will ever know.
+ */
+ const objectTypesPending =
+ !!objectName && typeof dataSource?.getObjectSchema === 'function' && !objectFields;
// [#3391] Server-resolved effective API operation set for this object
// (/me/permissions `apiOperations`). The Export button and handler AND their
// gate with this — a missing set (unrestricted object / old backend / no
@@ -1949,8 +2007,11 @@ export const ObjectGrid: React.FC = ({
setObjectSchema(schemaData);
}
} catch (err) {
- // Schema fetch failure for inline data is non-fatal; columns will
- // still fall back to heuristic inference.
+ // Schema fetch failure for inline data is non-fatal: the rows still
+ // draw. An untyped column does NOT fall back to heuristic inference,
+ // though: with no field types in hand it stays WITHHELD (drawn as the
+ // mask, handled as masked), because it could be a `password` /
+ // `secret` field — fail closed (objectui#10657, `objectTypesPending`).
console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
}
};
@@ -2611,7 +2672,7 @@ export const ObjectGrid: React.FC = ({
for (const gf of groupingFields) {
const fieldName = gf.field;
- const objectDefField = objectSchema?.fields?.[fieldName];
+ const objectDefField = objectFields?.[fieldName];
// Try to find a column override matching this field for type/options
const cols = normalizeColumns(schema.columns) as any[] | undefined;
const colOverride = cols?.find?.((c) => typeof c === 'object' && c?.field === fieldName);
@@ -2657,11 +2718,9 @@ export const ObjectGrid: React.FC = ({
}
return undefined;
};
- }, [schema.grouping, schema.columns, schema.objectName, objectSchema, translateOptions, t]);
+ }, [schema.grouping, schema.columns, schema.objectName, objectFields, translateOptions, t]);
- // objectui#10583 — a MASKED field is REFUSED as a grouping key, loudly, once
- // `objectSchema` has loaded (until then an untyped column's object-declared
- // type is unknown: the host-fetched window, objectui#10657, which folded objectui#10706).
+ // objectui#10583 — a MASKED field is REFUSED as a grouping key.
// Grouping by it printed the raw value as each group's label. Masking the
// label would not be enough: the buckets would still show which records
// share a credential, ordered by its raw value. So the entry is dropped (the
@@ -2669,26 +2728,40 @@ export const ObjectGrid: React.FC = ({
// one) and the drop is reported through the grid's warning channel. The rule
// is the column flag's: `isMaskedGridColumn` over the view column's type and
// the object-declared type.
+ //
+ // objectui#10657 (which folded objectui#10706) — the same rule's third
+ // argument: while the object's field types are unknown (`objectTypesPending`,
+ // the host-fetched window, or a failed read), an entry on a field whose view
+ // column authors no type is WITHHELD and so refused too, since its group
+ // label would print whatever the field holds. The group label follows the
+ // cell: a withheld cell draws the mask, and a withheld key labels no group.
+ // Once the types are known the entry groups again, unless its field is
+ // masked. The refusal of a withheld entry is not warned: it is not a finding
+ // about the authored grouping, and a failed read reports itself.
const groupingFieldsRaw = schema.grouping?.fields;
const maskedGroupingSignature = React.useMemo(() => {
const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? [];
const columnTypeOf = (field: string) =>
cols.find((c): c is ListColumn => typeof c === 'object' && c !== null && c.field === field)?.type;
- return JSON.stringify(
- usableGroupingFields(groupingFieldsRaw)
- .map((gf) => gf.field)
- .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectSchema?.fields?.[field]?.type)),
- );
- }, [groupingFieldsRaw, schema.columns, objectSchema]);
+ const refused = usableGroupingFields(groupingFieldsRaw)
+ .map((gf) => gf.field)
+ .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, objectTypesPending));
+ return JSON.stringify({
+ refused,
+ // The ones refused on a KNOWN masked type, which is what the warning names.
+ warned: refused.filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, false)),
+ });
+ }, [groupingFieldsRaw, schema.columns, objectFields, objectTypesPending]);
// Keyed on the authored array and the signature STRING, never on a memo's
// identity (AGENTS.md #10). Read only when something was refused: the
// unmasked path below hands `useGroupedData` the authored config itself.
const unmaskedGroupingFields = React.useMemo(() => {
- const masked: string[] = JSON.parse(maskedGroupingSignature);
- return usableGroupingFields(groupingFieldsRaw).filter((gf) => !masked.includes(gf.field));
+ const { refused }: { refused: string[] } = JSON.parse(maskedGroupingSignature);
+ return usableGroupingFields(groupingFieldsRaw).filter((gf) => !refused.includes(gf.field));
}, [groupingFieldsRaw, maskedGroupingSignature]);
+ const groupingRefusesNothing = maskedGroupingSignature === NO_GROUPING_REFUSAL;
useEffect(() => {
- const masked: string[] = JSON.parse(maskedGroupingSignature);
+ const { warned: masked }: { warned: string[] } = JSON.parse(maskedGroupingSignature);
if (masked.length === 0) return;
console.warn(
`[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked `
@@ -2698,7 +2771,7 @@ export const ObjectGrid: React.FC = ({
}, [maskedGroupingSignature, schema.objectName]);
const { groups, isGrouped, toggleGroup } = useGroupedData(
- maskedGroupingSignature === '[]' || !schema.grouping
+ groupingRefusesNothing || !schema.grouping
? schema.grouping
: { ...schema.grouping, fields: unmaskedGroupingFields },
data,
@@ -2955,7 +3028,7 @@ export const ObjectGrid: React.FC = ({
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
- if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
+ if (!Object.prototype.hasOwnProperty.call(objectFields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
@@ -2997,7 +3070,7 @@ export const ObjectGrid: React.FC = ({
// name-derived header (e.g. "Request title") even when the field has a
// localized label (e.g. "申请标题") on a non-English app.
const rawHeader = resolveColumnLabel(col.label)
- || resolveColumnLabel(objectSchema?.fields?.[col.field]?.label)
+ || resolveColumnLabel(objectFields?.[col.field]?.label)
|| col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
const header = schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader;
@@ -3008,7 +3081,13 @@ export const ObjectGrid: React.FC = ({
// Format hints (e.g. `text` + `format: 'phone'`) promote to the
// richer renderer (PhoneCellRenderer) via the grid's one shared
// resolve, `./cellRendererResolution` (objectui#8920).
- const objectDefField = objectSchema?.fields?.[col.field];
+ const objectDefField = objectFields?.[col.field];
+ // objectui#10657 — an untyped column while the object's field types
+ // are unknown is WITHHELD: drawn as the mask, never as text, and
+ // flagged masked (`isWithheldGridColumn`). No type is inferred for
+ // it — a heuristic guess from the name or the values is exactly
+ // how a credential field ended up drawn as text.
+ const withheld = isWithheldGridColumn(col.type, objectTypesPending);
// ⭐ ANNOTATED, and the annotation is load-bearing (objectui#6004).
// `objectSchema` is `useState`, so `objectDefField?.type` is
// `any` — and an `any` SPREAD into an object literal collapses the
@@ -3016,7 +3095,9 @@ export const ObjectGrid: React.FC = ({
// in it. Measured: without this annotation the emit below infers
// `any[]`, and `ObjectGridColumnDraft` cannot bite on any member. Naming
// the producer vocabulary here stops `any` at this one boundary.
- const baseInferredType: string | null = col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null;
+ const baseInferredType: string | null = withheld
+ ? null
+ : col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null;
// objectui#6458 — the column-level `format` read is RETIRED. The
// object-field fallback below is now the only road, which is what
// every measured author already used.
@@ -3025,7 +3106,7 @@ export const ObjectGrid: React.FC = ({
// `baseInferredType` is the DECLARED type the inline editor reads,
// `inferredType` the renderer key it promotes to.
const { rendererType: inferredType, Renderer } = resolveGridCellRendering({ type: baseInferredType, format: formatHint });
- const CellRenderer = inferredType ? Renderer : null;
+ const CellRenderer = withheld ? MaskedCellRenderer : inferredType ? Renderer : null;
// Build field metadata for cell renderers with objectDef enrichment
const fieldMeta: Record = { name: col.field, type: inferredType || 'text' };
@@ -3231,7 +3312,11 @@ export const ObjectGrid: React.FC = ({
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
- const fieldDef = objectSchema?.fields?.[fieldName];
+ const fieldDef = objectFields?.[fieldName];
+ // objectui#10657 — a bare field name carries no type of its own, so
+ // while the object's field types are unknown it is WITHHELD (see
+ // path A): the mask, no inferred type.
+ const withheld = isWithheldGridColumn(undefined, objectTypesPending);
const rawFieldLabel = fieldDef?.label;
const rawHeader = rawFieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
const header = schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, rawHeader) : rawHeader;
@@ -3252,10 +3337,10 @@ export const ObjectGrid: React.FC = ({
// members — it moved into the helper's return type, it did not go
// away.
const { declaredType, rendererType, Renderer } = resolveGridCellRendering({
- type: fieldDef?.type || inferColumnType({ field: fieldName }),
+ type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }),
format: fieldDef?.format,
});
- const CellRenderer = rendererType ? Renderer : null;
+ const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null;
// Build field metadata with objectDef enrichment
const fieldMeta: Record = { name: fieldName, type: rendererType || 'text' };
@@ -3410,19 +3495,24 @@ export const ObjectGrid: React.FC = ({
// `hasOwnProperty` rather than a truthiness read so an inherited
// name (`constructor`, `toString`) cannot be mistaken for a declared
// field and dropped.
- if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
+ if (!Object.prototype.hasOwnProperty.call(objectFields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
});
return fieldsToShow.map((fieldName) => {
- const fieldDef = objectSchema?.fields?.[fieldName];
+ const fieldDef = objectFields?.[fieldName];
+ // objectui#10657 — a row key carries no type of its own, so while
+ // the object's field types are unknown it is WITHHELD (see path A):
+ // the mask, no inferred type. This is the path a host-fetched grid
+ // with no authored columns paints first.
+ const withheld = isWithheldGridColumn(undefined, objectTypesPending);
// The same two resolves as path B, through the same shared owner
// (objectui#8920) — and the same objectui#6004 annotation, now
// carried by `GridCellRendering`'s `string | null` members.
const { declaredType, rendererType, Renderer } = resolveGridCellRendering({
- type: fieldDef?.type || inferColumnType({ field: fieldName }),
+ type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }),
format: fieldDef?.format,
});
- const CellRenderer = rendererType ? Renderer : null;
+ const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null;
const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
// Build field metadata with objectDef enrichment
@@ -3571,7 +3661,7 @@ export const ObjectGrid: React.FC = ({
});
return generatedColumns;
- }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]);
+ }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]);
// Formats this grid can actually deliver (objectui#2942): the server stream
// handles csv/xlsx/json, the client fallback only csv/json. Declared-but-dead
@@ -3709,10 +3799,13 @@ export const ObjectGrid: React.FC = ({
// stamps `TableColumn.masked` (`isMaskedGridColumn`, the narrow-only union
// of the column's type and the object-declared type), asked per KEY
// because the JSON branch writes whole records, including fields that are
- // not columns.
+ // not columns. While the object's field types are unknown (objectui#10657),
+ // a key with no column type of its own is WITHHELD and leaves both files
+ // too: a record field no column types is judged by its object type alone,
+ // and there is none yet.
const columnTypeByKey = new Map(generateColumns().map((c) => [c.accessorKey, c.type]));
const isMaskedKey = (key: string) =>
- isMaskedGridColumn(columnTypeByKey.get(key), objectSchema?.fields?.[key]?.type);
+ isMaskedGridColumn(columnTypeByKey.get(key), objectFields?.[key]?.type, objectTypesPending);
if (format === 'csv') {
const cols = generateColumns().filter((c) => c.accessorKey !== '_actions' && !isMaskedKey(c.accessorKey));
@@ -3733,7 +3826,7 @@ export const ObjectGrid: React.FC = ({
downloadFile(new Blob([JSON.stringify(unmasked, null, 2)], { type: 'application/json' }), fileNameFor('json'));
}
setShowExport(false);
- }, [data, schema.exportOptions, schema.operations?.export, effectiveApiOps, schema.objectName, objectName, objectSchema, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]);
+ }, [data, schema.exportOptions, schema.operations?.export, effectiveApiOps, schema.objectName, objectName, objectSchema, objectFields, objectTypesPending, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]);
// objectui#9050 step 2 — a refused filter, from EITHER of this component's
// two entries into the lowering: `schema.filter` (a render-time `useMemo`,
@@ -3834,7 +3927,7 @@ export const ObjectGrid: React.FC = ({
// line converts a tolerated null into a throw, which is how the protection
// would be lost a second time for a perfectly good reason.
if (!col || col.accessorKey === '_actions') return col;
- const fieldDef = (objectSchema as any)?.fields?.[col.accessorKey];
+ const fieldDef = objectFields?.[col.accessorKey];
if (!fieldDef) return col;
const next: ObjectGridColumnDraft = { ...col };
if (next.type == null && fieldDef.type) next.type = fieldDef.type;
@@ -3893,7 +3986,12 @@ export const ObjectGrid: React.FC = ({
// type ahead of the object's, and `type: 'text'` over a `secret` column
// must keep the flag. Written only when true, so every unmasked column
// reaches the table byte-identical to before.
- const rest = isMaskedGridColumn(producerType, objectSchema?.fields?.[col.accessorKey]?.type)
+ //
+ // objectui#10657 — the third argument: while the object's field types
+ // are unknown, a column with no type of its own is WITHHELD, and so
+ // flagged. `generateColumns()` gave it no inferred type and a mask for a
+ // cell; this is where every table path learns to withhold it as well.
+ const rest = isMaskedGridColumn(producerType, objectFields?.[col.accessorKey]?.type, objectTypesPending)
? { ...draft, masked: true }
: draft;
if (producerType == null) return rest;
@@ -5197,12 +5295,12 @@ export const ObjectGrid: React.FC = ({
// Honor `hidden: true` on the schema field def — internal/system fields
// (e.g. database_url, environment_id, is_system) shouldn't leak into the
// grid's record-detail drawer just because they're in the record payload.
- const isHidden = (key: string) => objectSchema?.fields?.[key]?.hidden === true;
+ const isHidden = (key: string) => objectFields?.[key]?.hidden === true;
// Split business fields from framework-managed system/audit/ownership
// columns via the shared classifier (branches on `field.system`), so the
// injected `owner_id` and friends land in the muted meta section rather than
// the business body — consistent with the grid's default-column derivation.
- const isSystem = (key: string) => isSystemManagedField(key, objectSchema?.fields?.[key]);
+ const isSystem = (key: string) => isSystemManagedField(key, objectFields?.[key]);
const regularFields = entries.filter(([key]) => !isSystem(key) && !isHidden(key));
const metaFields = entries.filter(([key]) => isSystem(key) && key !== '_id' && key !== 'id' && !isHidden(key));
@@ -5214,8 +5312,15 @@ export const ObjectGrid: React.FC = ({
return ;
}
+ // objectui#10657 — while the object's field types are unknown (the
+ // schema read is in flight, or it failed) no value here can be typed,
+ // and any of them could be a `password` / `secret` field: each is drawn
+ // WITHHELD, as the mask, like the grid's own untyped cells, never as the
+ // inferred text below.
+ if (objectTypesPending) return ;
+
// Use objectSchema field type for type-aware rendering
- const fieldDef = objectSchema?.fields?.[key];
+ const fieldDef = objectFields?.[key];
// Through the shared resolve, so the panel honours a `format` hint the
// same way the row above it does (objectui#8920). `rendererType` is null
// exactly when the key has no declared type, which is the guard this
@@ -5286,7 +5391,10 @@ export const ObjectGrid: React.FC = ({
{formatFieldLabel(key)}
- {String(coerceToSafeValue(value) ?? '')}
+
+ {/* Withheld while the field types are unknown (objectui#10657). */}
+ {objectTypesPending ? : String(coerceToSafeValue(value) ?? '')}
+
))}
@@ -5358,8 +5466,11 @@ export const ObjectGrid: React.FC = ({
// below pick amount / stage / date / percent by the field's NAME and print
// the raw value, so a masked column is never classified; it lands in the
// `col.cell` branch, and the title row routes it through `cell` as well.
+ // A WITHHELD column (objectui#10657: the object's field types are still
+ // unknown, and the column has no type of its own) is handled the same way:
+ // its `cell` draws the mask.
const isMaskedCardColumn = (key: string) =>
- isMaskedGridColumn(colMap.get(key)?.type, objectSchema?.fields?.[key]?.type);
+ isMaskedGridColumn(colMap.get(key)?.type, objectFields?.[key]?.type, objectTypesPending);
const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => {
if (isMaskedCardColumn(key)) return 'other';
diff --git a/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx
index 9149dede98..0db35c5935 100644
--- a/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx
+++ b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx
@@ -392,7 +392,11 @@ describe('ObjectGrid — FLS on the authored `columns` path (#6799)', () => {
,
);
- await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ // objectui#10657: with the schema in flight an untyped column is WITHHELD
+ // (drawn as the mask, never as text), so the row is waited for by its mask,
+ // not by the raw name it used to print.
+ await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••'));
+ expect(screen.queryByText('Acme expansion')).toBeNull();
expect(dataHeaders(container)).toEqual(['Name', 'Salary']);
});
diff --git a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx b/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx
index acbeea458f..c411cf4cca 100644
--- a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx
+++ b/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx
@@ -344,6 +344,12 @@ describe('ObjectGrid — a grouped grid discloses that it grouped a page (object
page: 1,
pageSize: 100,
onPageChange: () => {},
+ // objectui#10657: the ListView paging shape now hands the grid the
+ // object's field catalogue with its rows. Without it the grouping key
+ // (`business_unit`, no column type) is WITHHELD — refused — until the
+ // grid's own schema read lands, so the notice this case reads would sit
+ // behind that re-render of 100 rows.
+ objectFields: OBJECT_FIELDS,
});
await vi.waitFor(() => expect(notice()).toBeInTheDocument());
diff --git a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx
index b734bcdcf8..9e631131a6 100644
--- a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx
+++ b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx
@@ -245,7 +245,11 @@ describe('ObjectGrid — host-fetched rows still get the object-schema policy (#
const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) });
const { container } = renderHostFedGrid({}, ds);
- await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ // objectui#10657: with the schema in flight an untyped column is WITHHELD
+ // (drawn as the mask, never as text), so the row is waited for by its mask,
+ // not by the raw name it used to print.
+ await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••'));
+ expect(screen.queryByText('Acme expansion')).toBeNull();
releaseSchema!(OPPORTUNITY_SCHEMA);
@@ -300,7 +304,11 @@ describe('ObjectGrid — host-fetched rows still get the object-schema policy (#
const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) });
const { container } = renderHostFedGrid({}, ds);
- await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ // objectui#10657: with the schema in flight an untyped column is WITHHELD
+ // (drawn as the mask, never as text), so the row is waited for by its mask,
+ // not by the raw name it used to print.
+ await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••'));
+ expect(screen.queryByText('Acme expansion')).toBeNull();
// `objectSchema` is still `null`. Gating the legacy path on `objectName`
// alone would fall through to `if (!objectSchema) return []` and paint an
diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx
index 0612ede75b..89b9a0707a 100644
--- a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx
+++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx
@@ -318,7 +318,11 @@ describe('ObjectGrid — FLS on the inline-data path (#6723)', () => {
const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) });
const { container } = renderHostFedGrid({}, ds);
- await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ // objectui#10657: with the schema in flight an untyped column is WITHHELD
+ // (drawn as the mask, never as text), so the row is waited for by its mask,
+ // not by the raw name it used to print.
+ await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••'));
+ expect(screen.queryByText('Acme expansion')).toBeNull();
expect(dataHeaders(container)).toEqual(['Id', 'Name', 'Amount', 'Salary', 'Computed score']);
});
diff --git a/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx
new file mode 100644
index 0000000000..cccee85c3f
--- /dev/null
+++ b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx
@@ -0,0 +1,261 @@
+/**
+ * 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.
+ */
+
+/**
+ * `ObjectGrid`'s FIRST PAINT before its object schema settles — the
+ * objectui#10706 row of objectui#10657's enumeration.
+ *
+ * ## The defect
+ *
+ * On the host-fetched path (rows handed down as `data`, as `ListView` does)
+ * the rows paint before the grid's own `getObjectSchema` read lands. An
+ * untyped view column takes its type from that schema, so until it arrived an
+ * untyped column over a `password` field drew the raw credential as text, and
+ * grouping by it printed the credential as the group label. A failed read was
+ * swallowed as non-fatal, which left the column drawn in the clear for the
+ * life of the grid.
+ *
+ * ## The contract pinned here
+ *
+ * - HELD: an untyped column is WITHHELD — drawn as the mask, never as text,
+ * no raw value anywhere in the DOM (text or a `title`), nothing copied, and
+ * no group labelled by it.
+ * - REJECTED: it stays withheld (fail closed, never back to text).
+ * - SETTLED: the withholding lifts for a field declared `text`, and the
+ * declared `password` field keeps the mask.
+ * - HOST CATALOGUE: handed the object's fields (`objectFields`, what `ListView`
+ * passes), the grid has no window: first paint draws each column from its
+ * declared type while its own read is still held.
+ * - THE RECORD PANEL a row opens under overlay navigation, with no declared
+ * fields in hand, used to print every value of the record by inference: it
+ * withholds them too after a failed read.
+ *
+ * ## Controls
+ *
+ * Every mount carries `Name`, a column that AUTHORS `type: 'text'`: its type
+ * does not wait on the schema, so it draws, tooltips and copies its value in
+ * every arm. That is what makes each absence a reading and not a grid that
+ * drew nothing. The group-label case groups a second mount by that column and
+ * requires its label.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ActionProvider } from '@object-ui/react';
+import { registerAllFields } from '@object-ui/fields';
+import { ObjectGrid } from '../ObjectGrid';
+
+registerAllFields();
+
+const MASK = '••••••';
+const RAW_KEY = 'RAW-ZULU-10657';
+const RAW_NOTE = 'NOTE-TANGO-10657';
+const CONTROL_VALUE = 'Ada';
+
+const ROWS = [{ id: 'r1', name: CONTROL_VALUE, api_key: RAW_KEY, note: RAW_NOTE }];
+
+/** What the object declares: one credential, one ordinary text field. */
+const FIELDS = {
+ id: { type: 'text', label: 'Id' },
+ name: { type: 'text', label: 'Name' },
+ api_key: { type: 'password', label: 'API Key' },
+ note: { type: 'text', label: 'Note' },
+};
+
+/** `Name` authors its type (the control); `API Key` and `Note` author none. */
+const COLUMNS = [
+ { field: 'name', label: 'Name', type: 'text' },
+ { field: 'api_key', label: 'API Key' },
+ { field: 'note', label: 'Note' },
+];
+
+let writeText: ReturnType;
+const ORIGINAL_INNER_WIDTH = window.innerWidth;
+
+beforeAll(() => {
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = vi.fn() as any;
+ }
+});
+
+beforeEach(() => {
+ // Desktop: the sub-768px layout is the card list, which has no data-table.
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
+ writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+});
+
+afterEach(() => {
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: ORIGINAL_INNER_WIDTH });
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+/** A data source whose schema read the test settles by hand. */
+function heldDataSource() {
+ let release!: (def: unknown) => void;
+ let reject!: (err: unknown) => void;
+ const pending = new Promise((res, rej) => { release = res; reject = rej; });
+ const ds = { getObjectSchema: vi.fn(() => pending) } as any;
+ return { ds, release, reject };
+}
+
+/** Rows handed down by a host, the way `ListView` hands them. */
+function renderHostFed(ds: unknown, extra: { grouping?: unknown; objectFields?: unknown; navigation?: unknown } = {}) {
+ const { grouping, objectFields, navigation } = extra;
+ return render(
+
+
+ ,
+ );
+}
+
+/** The body cell under the header whose text is `label`, in the control row. */
+function cellUnder(label: string): HTMLElement {
+ const row = screen.getByText(CONTROL_VALUE).closest('tr');
+ expect(row, 'CONTROL: the data row rendered').not.toBeNull();
+ const headers = Array.from(row!.closest('table')!.querySelectorAll('thead th'));
+ const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label);
+ expect(index, `CONTROL: a "${label}" column header rendered`).toBeGreaterThanOrEqual(0);
+ return row!.children[index] as HTMLElement;
+}
+
+const copied = () => writeText.mock.calls.map((call) => call[0]);
+
+/** The text control draws, copies and tooltips its value: the grid is live. */
+function expectControlLive() {
+ const name = cellUnder('Name');
+ expect(name.textContent, 'CONTROL: the authored text column draws its value').toContain(CONTROL_VALUE);
+ fireEvent.keyDown(name, { key: 'c', ctrlKey: true });
+ expect(copied(), 'CONTROL: the authored text column copies its value').toEqual([CONTROL_VALUE]);
+ writeText.mockClear();
+}
+
+/** A column drawn as the mask: no raw text, no raw attribute, no copy. */
+function expectWithheld(label: string, raw: string) {
+ const cell = cellUnder(label);
+ expect(cell.textContent, `${label}: drawn as the mask`).toContain(MASK);
+ fireEvent.keyDown(cell, { key: 'c', ctrlKey: true });
+ expect(copied(), `${label}: Ctrl+C copies nothing`).toEqual([]);
+ // Text OR attribute: the `title` tooltip is where the raw value used to sit.
+ expect(document.body.innerHTML, `${label}: the raw value is nowhere in the DOM`).not.toContain(raw);
+}
+
+describe('ObjectGrid — an untyped column is withheld until the object schema settles (objectui#10657)', () => {
+ it('HELD: the untyped columns draw the mask, never the raw value; the typed text column draws', async () => {
+ const { ds } = heldDataSource();
+ renderHostFed(ds);
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ expect(ds.getObjectSchema, 'CONTROL: the grid is waiting on its own schema read').toHaveBeenCalled();
+
+ expectControlLive();
+ expectWithheld('API Key', RAW_KEY);
+ expectWithheld('Note', RAW_NOTE);
+ });
+
+ it('HELD: no group is labelled by an untyped column; grouping by the typed text column still labels', async () => {
+ const { ds } = heldDataSource();
+ renderHostFed(ds, { grouping: { fields: [{ field: 'api_key' }] } });
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ expect(document.querySelectorAll('.group-label'), 'no group is labelled by the withheld key').toHaveLength(0);
+ expect(document.body.innerHTML, 'the raw value is nowhere in the DOM').not.toContain(RAW_KEY);
+ cleanup();
+
+ // CONTROL — the same held window, grouped by the column that authors its
+ // type: the grouping runs and its label is the value.
+ const control = heldDataSource();
+ renderHostFed(control.ds, { grouping: { fields: [{ field: 'name' }] } });
+ await waitFor(() =>
+ expect(Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent)).toEqual([CONTROL_VALUE]));
+ });
+
+ it('REJECTED: a failed schema read keeps the untyped columns withheld — fail closed, never back to text', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { ds, reject } = heldDataSource();
+ renderHostFed(ds);
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ reject(new Error('metadata read refused'));
+ // The grid swallows the failure with its own warning: that is the signal
+ // the rejection has been handled, not merely issued.
+ await waitFor(() =>
+ expect(warn.mock.calls.some((c) => String(c[0]).includes('Failed to fetch objectSchema for inline data'))).toBe(true));
+
+ expectControlLive();
+ expectWithheld('API Key', RAW_KEY);
+ expectWithheld('Note', RAW_NOTE);
+ });
+
+ it('REJECTED: grouping by the untyped column labels no group after the failed read', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { ds, reject } = heldDataSource();
+ renderHostFed(ds, { grouping: { fields: [{ field: 'api_key' }] } });
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ reject(new Error('metadata read refused'));
+ await waitFor(() => expect(console.warn).toHaveBeenCalled());
+ expect(document.querySelectorAll('.group-label')).toHaveLength(0);
+ expect(document.body.innerHTML).not.toContain(RAW_KEY);
+ });
+
+ it('SETTLED: the declared text field draws once the schema lands; the declared password keeps the mask', async () => {
+ const { ds, release } = heldDataSource();
+ renderHostFed(ds);
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ expect(cellUnder('Note').textContent, 'withheld while held').toContain(MASK);
+
+ release({ name: 'masked_first_paint', fields: FIELDS });
+ // CONTROL — the withholding lifts: a field the object declares `text`.
+ await waitFor(() => expect(cellUnder('Note').textContent).toContain(RAW_NOTE));
+ expectControlLive();
+ // The declared `password` field is masked from its declaration now.
+ expectWithheld('API Key', RAW_KEY);
+ });
+
+ it('HOST CATALOGUE: handed `objectFields`, first paint draws from the declared types while its own read is held', async () => {
+ const { ds } = heldDataSource();
+ renderHostFed(ds, { objectFields: FIELDS });
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+
+ // No window: the declared `text` field draws its value at first paint…
+ expect(cellUnder('Note').textContent, 'the declared text field draws at first paint').toContain(RAW_NOTE);
+ expectControlLive();
+ // …and the declared `password` field is the mask from the same paint.
+ expectWithheld('API Key', RAW_KEY);
+ });
+
+ it('REJECTED: the record panel a row opens draws every value withheld', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { ds, reject } = heldDataSource();
+ renderHostFed(ds, { navigation: { mode: 'drawer' } });
+ await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument());
+ reject(new Error('metadata read refused'));
+ await waitFor(() =>
+ expect(warn.mock.calls.some((c) => String(c[0]).includes('Failed to fetch objectSchema for inline data'))).toBe(true));
+
+ fireEvent.click(screen.getByText(CONTROL_VALUE));
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
+ const dialog = screen.getByRole('dialog');
+ // CONTROL — the panel rendered this record's fields, not an empty shell.
+ expect(within(dialog).getByText('Api key')).toBeInTheDocument();
+ expect(dialog.textContent, 'the panel draws the values as the mask').toContain(MASK);
+ expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY);
+ expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE);
+ });
+});
diff --git a/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx b/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx
index 69a492a3b3..11aed818c4 100644
--- a/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx
+++ b/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx
@@ -429,8 +429,12 @@ describe('[#4296] one batched call per (object, operation) per page', () => {
it('a 50-row page issues 2 explain calls, not 100', async () => {
const rows = bigPage(50);
for (const r of rows) server.verdicts.set(r.id, { update: true, delete: true });
- renderGrid({ rows });
- await waitFor(() => expect(screen.getByText('Row 49')).toBeInTheDocument());
+ const { container } = renderGrid({ rows });
+ // objectui#10657: the fixture's `name` column authors no type, so until the
+ // grid's own schema read lands it is WITHHELD (drawn as the mask) and its
+ // text is no readiness signal: on this many rows the settled re-render
+ // outran the wait. The rows being on screen is: count them.
+ await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(rows.length));
await settle(2);
expect(server.calls.length).toBe(2);
@@ -460,8 +464,12 @@ describe('[#4296] one batched call per (object, operation) per page', () => {
const cap = EXPLAIN_BATCH_MAX_RECORD_IDS;
const rows = bigPage(cap + Math.min(50, cap));
for (const r of rows) server.verdicts.set(r.id, { update: true, delete: true });
- renderGrid({ rows });
- await waitFor(() => expect(screen.getByText(`Row ${rows.length - 1}`)).toBeInTheDocument());
+ const { container } = renderGrid({ rows });
+ // objectui#10657: the fixture's `name` column authors no type, so until the
+ // grid's own schema read lands it is WITHHELD (drawn as the mask) and its
+ // text is no readiness signal: on this many rows the settled re-render
+ // outran the wait. The rows being on screen is: count them.
+ await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(rows.length));
await settle(4);
expect(server.calls.length).toBe(4);
diff --git a/packages/plugin-grid/src/__tests__/serverPagination.test.tsx b/packages/plugin-grid/src/__tests__/serverPagination.test.tsx
index 79fa8efc7d..277e64f26d 100644
--- a/packages/plugin-grid/src/__tests__/serverPagination.test.tsx
+++ b/packages/plugin-grid/src/__tests__/serverPagination.test.tsx
@@ -198,12 +198,18 @@ describe('ObjectGrid — external (host-driven) manual pagination (#2212)', () =
// ceil(3125 / 50) = 63 — proves the footer uses the host total, not the
// 50-row window it was handed.
await waitFor(() => expect(container.textContent).toContain('63'));
- await waitFor(() => expect(screen.getByText('Row 0')).toBeInTheDocument());
+ // objectui#10657: the fixture's `name` column authors no type, so until the
+ // grid's own schema read lands it is WITHHELD (drawn as the mask) and its
+ // text is no readiness signal. The host's window being drawn is: count it.
+ await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(PAGE_SIZE));
});
it('calls the external onPageChange when the footer turns the page (no client slice)', async () => {
const { container, onPageChange } = renderExternal();
- await waitFor(() => expect(screen.getByText('Row 0')).toBeInTheDocument());
+ // objectui#10657: the fixture's `name` column authors no type, so until the
+ // grid's own schema read lands it is WITHHELD (drawn as the mask) and its
+ // text is no readiness signal. The host's window being drawn is: count it.
+ await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(PAGE_SIZE));
const navButtons = Array.from(container.querySelectorAll('button')).filter(
(b) => !(b as HTMLButtonElement).disabled,
);
diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts
index 624977140c..19406bfdc6 100644
--- a/packages/plugin-grid/src/maskedColumn.ts
+++ b/packages/plugin-grid/src/maskedColumn.ts
@@ -10,23 +10,17 @@ import { isMaskedFieldType } from '@object-ui/fields';
/**
* Is this grid column's cell drawn as a MASK, given the type the column
- * carries and the type the object schema declares for its field?
- * (objectui#10583)
+ * carries, the type the object schema declares for its field, and whether the
+ * object's field types are known yet? (objectui#10583, objectui#10657)
*
* `ObjectGrid` stamps `TableColumn.masked` from this answer, and `data-table`
* obeys the flag: no Ctrl+C / Cmd+C copy, no `title` tooltip, no column in its
- * CSV export, no inline edit. `ObjectGrid` also asks it directly where it
- * handles values itself: its client export (CSV and JSON) leaves masked fields
- * out, its mobile card draws them through `cell`, and its grouping refuses them
- * as keys. `@object-ui/components`
- * cannot import `@object-ui/fields`, which is why the answer is computed HERE,
- * on the producer side, and handed across as a flag.
- *
- * ⚠️ Every one of those paths asks with the object-declared type, which the grid
- * reads from the object schema it fetches after first paint. On the host-fetched
- * path (rows handed down as `data`) an untyped view column is therefore
- * unmasked until that schema arrives, and for good if the read fails
- * (objectui#10657, which folded objectui#10706).
+ * CSV export, no inline edit, no client search or sort, no auto width from the
+ * values. `ObjectGrid` also asks it directly where it handles values itself:
+ * its client export (CSV and JSON) leaves masked fields out, its mobile card
+ * draws them through `cell`, and its grouping refuses them as keys.
+ * `@object-ui/components` cannot import `@object-ui/fields`, which is why the
+ * answer is computed HERE, on the producer side, and handed across as a flag.
*
* The rule itself is NOT restated here — it is `isMaskedFieldType()` from
* `@object-ui/fields`, the one authority for "is this field type's cell drawn
@@ -42,9 +36,49 @@ import { isMaskedFieldType } from '@object-ui/fields';
* credential. The declared cost is the mirror case — an object `text` column
* a view authors as `password` is masked AND flagged, which is the answer its
* cell already gives.
+ *
+ * **Fail closed while the object's types are unknown** (the third argument,
+ * see {@link isWithheldGridColumn}): a column with no type of its own is
+ * answered as masked until they are known, and for good if their read failed.
+ * The argument is required so that no caller can forget the window.
+ */
+export function isMaskedGridColumn(
+ columnType: unknown,
+ objectFieldType: unknown,
+ objectTypesPending: boolean,
+): boolean {
+ return isMaskedType(columnType)
+ || isMaskedType(objectFieldType)
+ || isWithheldGridColumn(columnType, objectTypesPending);
+}
+
+/**
+ * Is this grid column WITHHELD: handled as masked because nothing can say yet
+ * whether it is? (objectui#10657, which folded objectui#10706)
+ *
+ * An untyped view column over an object field takes its type from the object
+ * schema. While an object-bound grid is still waiting for that schema (its
+ * rows can paint first: a host hands them down as `data`), or after the read
+ * failed, the column's field could be a `password` / `secret` one, and
+ * drawing it as text would print the credential. So the column is withheld:
+ * its cell draws the mask (`MaskedCellRenderer` from `@object-ui/fields`),
+ * it carries the masked flag, and every path `isMaskedGridColumn` guards
+ * treats it as masked. It is never drawn as text in the meantime, and it stays
+ * withheld when the read fails (fail closed).
+ *
+ * A column that authors its own `type` is not withheld: that type decides its
+ * draw (`type: 'password'` masks, `type: 'text'` draws the text it was told
+ * to), exactly as it does once the schema has loaded. A host that already
+ * holds the object's fields hands them to the grid (`objectFields`, as
+ * `ListView` does), so there is no window to withhold anything in.
+ *
+ * `objectTypesPending` is the grid's own reading: an object to ask about, a
+ * data source that can answer `getObjectSchema`, and no field catalogue in
+ * hand. With no `getObjectSchema` there is nothing to wait for, and the
+ * authored column types are all the grid will ever know.
*/
-export function isMaskedGridColumn(columnType: unknown, objectFieldType: unknown): boolean {
- return isMaskedType(columnType) || isMaskedType(objectFieldType);
+export function isWithheldGridColumn(columnType: unknown, objectTypesPending: boolean): boolean {
+ return objectTypesPending && !(typeof columnType === 'string' && columnType.length > 0);
}
function isMaskedType(fieldType: unknown): boolean {
diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx
index dffb7bf592..27af37d8a3 100644
--- a/packages/plugin-list/src/ListView.tsx
+++ b/packages/plugin-list/src/ListView.tsx
@@ -5035,6 +5035,20 @@ export const ListView = React.forwardRef(({
// and why it is still the correct value to hand down.
? {}
: { data })}
+ {...(viewComponentSchema.type === 'object-grid' && objectDef?.fields
+ // objectui#10657 — the grid is handed the rows this component
+ // fetched, so they paint before the grid's own read of the
+ // object definition settles; until then an untyped column over a
+ // `password` / `secret` field has no type, and the grid can only
+ // withhold it. This component read that definition BEFORE its
+ // rows (the data fetch waits for `objectDefLoaded`), so it hands
+ // the field catalogue down with them and the grid has no window
+ // at all. `objectFields` is the host channel for the catalogue
+ // (decision batch #70): `SchemaRenderer` refuses an AUTHORED
+ // one, and this React prop is not authored. Only the grid reads
+ // it, so only the grid is handed it.
+ ? { objectFields: objectDef.fields }
+ : {})}
loading={loading}
onRowSelect={setSelectedRows}
{...(paginate && serverTotal != null
diff --git a/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx b/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx
new file mode 100644
index 0000000000..0cab8806cf
--- /dev/null
+++ b/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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.
+ */
+
+/**
+ * `ListView` hands the grid the field catalogue it already holds WITH the
+ * rows, so the grid has no first-paint window (objectui#10657, which folded
+ * objectui#10706).
+ *
+ * `ListView` fetches the rows and hands them down as `data`, so they paint
+ * before the grid's own `getObjectSchema` read settles; in that window an
+ * untyped column over a `password` field has no type, and the grid can only
+ * withhold it (`maskedFirstPaint-10657.test.tsx` in `@object-ui/plugin-grid`
+ * pins that side, including what the grid draws once it is handed
+ * `objectFields`). This list read the object definition BEFORE its rows, so it
+ * passes `objectFields` beside `data` — the host channel `SchemaRenderer`
+ * keeps for the catalogue (objectui#8818).
+ *
+ * What is pinned is the HANDOFF, read off a probe registered as `object-grid`:
+ * every props bag that carries rows carries the declared catalogue too, so no
+ * render ever hands the grid rows without their types. The control is the
+ * failed definition read: the rows still arrive, and no catalogue with them,
+ * which is the case the grid withholds on its own.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+import { cleanup, render, waitFor } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+import { I18nProvider, SchemaRendererProvider } from '@object-ui/react';
+import type { ListViewSchema } from '@object-ui/types';
+import { ListView } from '../ListView';
+
+const OBJECT = 'vault';
+
+const FIELDS = {
+ id: { type: 'text', label: 'Id', hidden: true },
+ name: { type: 'text', label: 'Name' },
+ api_key: { type: 'password', label: 'API Key' },
+};
+
+const ROWS = [{ id: 'v1', name: 'Ada', api_key: 'RAW-ZULU-10657' }];
+
+/** Records every props bag `SchemaRenderer` hands the `object-grid` node. */
+const GridProbe = vi.fn((_props: Record) => );
+
+beforeAll(() => {
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = vi.fn() as any;
+ }
+});
+
+beforeEach(() => {
+ GridProbe.mockClear();
+ ComponentRegistry.register('object-grid', GridProbe as never);
+});
+
+afterEach(() => {
+ ComponentRegistry.unregister?.('object-grid');
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function makeDataSource(schemaRead: 'ok' | 'rejected') {
+ return {
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false })),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: vi.fn(async (name: string) => {
+ if (schemaRead === 'rejected') throw new Error('metadata unavailable');
+ return { name, label: 'Vault', fields: FIELDS };
+ }),
+ } as any;
+}
+
+function renderList(ds: unknown) {
+ const schema = { type: 'list-view', objectName: OBJECT, columns: ['name', 'api_key'] } as unknown as ListViewSchema;
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+/** Every props bag the grid probe received that carried rows. */
+const bagsWithRows = () =>
+ GridProbe.mock.calls
+ .map((call) => call[0])
+ .filter((props) => Array.isArray(props.data) && (props.data as unknown[]).length > 0);
+
+describe('ListView → object-grid — the field catalogue travels with the rows (objectui#10657)', () => {
+ it('every render that hands the grid rows hands it the declared fields too', async () => {
+ const ds = makeDataSource('ok');
+ renderList(ds);
+ await waitFor(() => expect(bagsWithRows().length).toBeGreaterThan(0));
+
+ for (const bag of bagsWithRows()) {
+ expect(bag.objectFields, 'the rows never arrive without their field types').toEqual(FIELDS);
+ }
+ });
+
+ it('CONTROL: after a failed definition read the rows still arrive, with no catalogue', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const ds = makeDataSource('rejected');
+ renderList(ds);
+ await waitFor(() => expect(bagsWithRows().length).toBeGreaterThan(0));
+
+ for (const bag of bagsWithRows()) {
+ expect('objectFields' in bag, 'nothing is handed down when nothing was read').toBe(false);
+ }
+ });
+});
diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts
index add5c58358..ec74d4d105 100644
--- a/packages/types/src/data-display.ts
+++ b/packages/types/src/data-display.ts
@@ -738,19 +738,20 @@ export interface TableColumn {
*
* `RelatedList` and `ObjectDataTable` fail closed: while their object
* definition is in flight, or after its read failed, they stamp EVERY column.
- * Not covered there: in that same window their cells draw a field the object
- * declares `password` / `secret` as text, because the mask comes from that
- * declaration (objectui#10657).
- *
- * Once the grid's object schema has loaded, `ObjectGrid` also leaves every
- * masked field of its object out of its own client export, draws those
- * fields through `cell` on its mobile card, and refuses them as grouping
- * keys. Not covered there: on the host-fetched path (rows handed down as
- * `data`, as `ListView` and `ObjectView` do), the grid's guards and the
- * cell's own mask depend on the object schema, which the grid fetches after
- * first paint, so until it arrives, and for good if that read fails, an
- * untyped view column over a `password` / `secret` field draws and hands out
- * the raw value (objectui#10657, which folded objectui#10706); the
+ *
+ * WITHHELD columns (objectui#10657, which folded objectui#10706): while an
+ * object-bound producer's field types are unknown (the definition is in
+ * flight, or its read failed), a column it cannot type draws as the mask
+ * instead of as text, and carries this flag; it stays so when the read
+ * failed. In `ObjectGrid` and `ObjectDataTable` that is a column with no
+ * authored `type`; `RelatedList` draws its cells from the object's types
+ * only, so there it is every column without a `cell` of its own. A host that
+ * already holds the object's fields hands them to `ObjectGrid`
+ * (`objectFields`, as `ListView` does), so that grid has no such window.
+ *
+ * `ObjectGrid` also leaves every masked or withheld field of its object out
+ * of its own client export, draws those fields through `cell` on its mobile
+ * card, and refuses them as grouping keys. Not covered there: the
* server-streamed export (`exportDownload`) sends the masked columns as
* before and relies on the server's masking; and the client JSON export, and
* this table's CSV export of a lookup column, write an expanded lookup