Skip to content

Commit dbfebe7

Browse files
committed
fix(components): render a bare-string data-table emptyAction instead of dropping it
`DataTableSchema.emptyAction` is declared `SchemaNode` on both published faces (`packages/types/src/data-display.ts` and its Zod twin), and `SchemaNode` is `BaseSchema | string | number | boolean | null | undefined`. The empty-state render path additionally required `typeof … === 'object'`, so an authored `emptyAction: 'Create the first record'` rendered nothing and reported nothing — declared wider than enforced, failing in the direction that loses the author's content without a diagnostic. The declaration is unchanged; it was the correct side. objectui#7105 (director seat, decision batch #69, 2026-09-07) settled the identical shape one slot over on `EmptySchema.action` as relax the renderer, do not narrow the declaration, and shipped it through the repo's permanent `toRenderableSchema` bridge. Nothing had to be invented here either. The truthiness leg is kept on purpose, and the `&&` chain became a ternary. Together they make the slot behave exactly as handing the raw node to `SchemaRenderer` would: `''` / `0` / `false` keep rendering nothing (pinned, objectui#4548) because they never reach the bridge, whose `String` mapping would otherwise turn the last two into the text "0" and "false". The ternary also closes a measured leak — with `emptyAction: 0` the old chain evaluated to the number `0` itself, which React renders as a stray "0" inside the empty state. The slot still mounts through `SchemaRenderer` rather than resolving the registry, so the single central `visibleWhen` gate still applies unchanged (objectui#5926 gap 1); its pin is the regression control here and is green on both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
1 parent f52a9d7 commit dbfebe7

3 files changed

Lines changed: 243 additions & 5 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@object-ui/components': minor
3+
---
4+
5+
`data-table`'s `emptyAction` now accepts the whole `SchemaNode` union it declares — a
6+
bare string node renders instead of vanishing (objectui#8331).
7+
8+
**Behaviour change on a published surface, deliberately.** `DataTableSchema.emptyAction`
9+
is declared `SchemaNode` on both published faces — `packages/types/src/data-display.ts`
10+
and its Zod twin in `packages/types/src/zod/data-display.zod.ts` — and `SchemaNode` is
11+
`BaseSchema | string | number | boolean | null | undefined`. The empty-state render path
12+
additionally required `typeof … === 'object'`, so an authored
13+
`emptyAction: 'Create the first record'` rendered nothing and reported nothing: declared
14+
wider than enforced, failing in the direction that loses the author's content without a
15+
diagnostic. A node that renders nothing today therefore starts rendering.
16+
17+
The declaration is unchanged. It was the correct side — the object-only test was not
18+
protecting the renderer from anything it cannot handle. What `SchemaRenderer` does with a
19+
primitive has been pinned since objectui#4548: a non-empty string renders as its own
20+
text, and `''` / `0` / `false` render nothing. The guard was discarding input the
21+
declaration promises to accept.
22+
23+
Same defect, same answer, one slot over: objectui#7105 (director seat, decision batch
24+
#69, 2026-09-07) settled the identical shape on `EmptySchema.action` as **relax the
25+
renderer, do not narrow the declaration**, and shipped it through the repo's existing
26+
permanent bridge `toRenderableSchema`. Nothing had to be invented here either.
27+
28+
**A second, smaller behaviour change ships with it, and it is a bug fix.** With
29+
`emptyAction: 0` the old `&&` chain evaluated to the number `0` itself, which React
30+
renders — so a stray `"0"` appeared inside the empty state. That is the numeric-falsy JSX
31+
trap rather than a decision, it was measured rather than assumed, and the ternary that
32+
replaces the chain yields `null` instead.
33+
34+
**What did NOT change.** The slot still mounts through `SchemaRenderer` rather than
35+
resolving the registry itself, so the single central `visibleWhen` gate still applies to
36+
an authored `emptyAction` exactly as before (objectui#5926 gap 1, pinned in
37+
`data-table-empty-action-visible-when.test.tsx`, green on both sides of this change).
38+
`''` / `0` / `false` still render nothing, which is the same answer `SchemaRenderer`
39+
gives them: the truthiness leg is kept precisely so those two never reach the bridge,
40+
whose `String` mapping would otherwise turn them into the text `"0"` and `"false"`.
41+
42+
**Migration.** Nothing has to change. Metadata that already authored an object node in
43+
this slot is unaffected. Metadata that authored a bare string was rendering nothing and
44+
now renders that string — which is what the declaration always promised.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#8331 — a `data-table`'s `emptyAction` accepts any `SchemaNode`, so a
11+
* bare string node must RENDER rather than be silently dropped.
12+
*
13+
* ## The defect this pins closed
14+
*
15+
* `DataTableSchema.emptyAction` is declared `SchemaNode` on both published
16+
* faces — `packages/types/src/data-display.ts` and its Zod twin in
17+
* `packages/types/src/zod/data-display.zod.ts` — and `SchemaNode` is
18+
* `BaseSchema | string | number | boolean | null | undefined`. The render path
19+
* additionally required `typeof … === 'object'`, so an authored
20+
* `emptyAction: 'Create the first record'` rendered NOTHING and reported
21+
* nothing: declared wider than enforced, failing in the direction that loses
22+
* the author's content without a diagnostic.
23+
*
24+
* The object-only test was not protecting the renderer from anything it cannot
25+
* handle. What `SchemaRenderer` does with a primitive is pinned in
26+
* `packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx`
27+
* (objectui#4548): a non-empty string renders as its own text, and `''` / `0` /
28+
* `false` render nothing. The guard was discarding input the declaration
29+
* promises to accept.
30+
*
31+
* Same shape, same answer, one slot over: objectui#7105 (director seat,
32+
* decision batch #69, 2026-09-07) ruled the identical defect on
33+
* `EmptySchema.action` — RELAX THE RENDERER, do not narrow the declaration.
34+
*
35+
* ## Why the falsy primitives are pinned here too
36+
*
37+
* The truthiness leg of the guard is KEPT, and that is a decision, not an
38+
* oversight. `0` / `false` / `''` render nothing both before and after this
39+
* change, which is exactly the answer `SchemaRenderer` itself gives them
40+
* (objectui#4548 pins them as "renders nothing … rather than becoming '0' /
41+
* 'false'"). Routing them through the bridge instead — `toRenderableSchema`
42+
* maps `number` / `boolean` onto `String(node)` — would turn them into the
43+
* text "0" and "false", a SECOND behaviour change on a published surface that
44+
* nothing ruled and that would contradict that pin. So the slot's answer stays
45+
* the platform's uniform one, and these cases stand guard over a future
46+
* "tidy" of the leg into a nullish test.
47+
*
48+
* ## Discriminating power
49+
*
50+
* The string case below FAILS on the unfixed guard — that is what makes it
51+
* evidence. The object case and the sibling suite
52+
* `data-table-empty-action-visible-when.test.tsx` are the regression controls
53+
* and are green on both sides; every case additionally asserts the fixture
54+
* really reached the EMPTY STATE, since that is the only place this slot
55+
* renders and a fixture that misses it would be green for the wrong reason.
56+
*/
57+
import { describe, it, expect } from 'vitest';
58+
import { render, screen } from '@testing-library/react';
59+
import '@testing-library/jest-dom';
60+
import React from 'react';
61+
import { ComponentRegistry } from '@object-ui/core';
62+
import { SchemaRenderer } from '@object-ui/react';
63+
import '../data-table';
64+
65+
const CTA_TYPE = 'test:data-table-empty-action-primitive-probe';
66+
ComponentRegistry.register(CTA_TYPE, () => (
67+
<span data-testid="empty-action-cta">Create the first record</span>
68+
));
69+
70+
/** The empty state renders only with no rows, no loading and no error. */
71+
function renderEmptyTable(emptyAction: unknown) {
72+
return render(
73+
<SchemaRenderer
74+
schema={{
75+
type: 'data-table',
76+
columns: [{ header: 'Name', accessorKey: 'name' }],
77+
data: [],
78+
pagination: false,
79+
searchable: false,
80+
selectable: false,
81+
rowActions: false,
82+
emptyAction,
83+
} as never}
84+
/>,
85+
);
86+
}
87+
88+
/**
89+
* Guard against a green-for-the-wrong-reason pass: assert the fixture really
90+
* landed in the empty state, where `emptyAction` is the only thing that can
91+
* render authored content.
92+
*/
93+
function expectEmptyStateReached() {
94+
expect(screen.getByText(/No results found|table\.noResults/)).toBeInTheDocument();
95+
}
96+
97+
describe('objectui#8331 — data-table emptyAction accepts the whole SchemaNode union', () => {
98+
describe('the declared change: a bare string node renders instead of vanishing', () => {
99+
it('renders a bare string emptyAction as its own text', () => {
100+
const { container } = renderEmptyTable('Create the first record');
101+
expectEmptyStateReached();
102+
expect(container.textContent).toContain('Create the first record');
103+
});
104+
105+
it('does not report the string as an unknown component type', () => {
106+
const { container } = renderEmptyTable('Create the first record');
107+
expectEmptyStateReached();
108+
expect(container.textContent).not.toContain('Unknown component type');
109+
});
110+
});
111+
112+
describe('unchanged paths', () => {
113+
/**
114+
* The empty state's own chrome, with no authored node in the slot. Every
115+
* "renders nothing" case below is asserted against THIS, not against a
116+
* substring guess — the table's own text is not this suite's to predict.
117+
*/
118+
function emptyStateBaselineText() {
119+
const { container, unmount } = renderEmptyTable(undefined);
120+
const text = container.textContent ?? '';
121+
unmount();
122+
return text;
123+
}
124+
125+
it('still renders an object emptyAction through the registry', () => {
126+
renderEmptyTable({ type: CTA_TYPE });
127+
expectEmptyStateReached();
128+
expect(screen.getByTestId('empty-action-cta')).toBeInTheDocument();
129+
});
130+
131+
it('renders nothing for a nullish emptyAction', () => {
132+
const baseline = emptyStateBaselineText();
133+
for (const value of [null, undefined] as const) {
134+
const { container, unmount } = renderEmptyTable(value);
135+
expectEmptyStateReached();
136+
expect(container.textContent).toBe(baseline);
137+
unmount();
138+
}
139+
});
140+
141+
it('renders nothing for the falsy primitives, exactly as SchemaRenderer does', () => {
142+
// objectui#4548 pins `''` / `0` / `false` as rendering nothing. The slot
143+
// must keep giving them the platform's answer, not the bridge's `String()`
144+
// form — this case fails the moment the truthiness leg is "tidied" into a
145+
// nullish test, because `0` and `false` would arrive as the text "0" and
146+
// "false".
147+
const baseline = emptyStateBaselineText();
148+
for (const value of ['', 0, false] as const) {
149+
const { container, unmount } = renderEmptyTable(value);
150+
expectEmptyStateReached();
151+
expect(container.textContent).toBe(baseline);
152+
unmount();
153+
}
154+
});
155+
});
156+
});

‎packages/components/src/renderers/complex/data-table.tsx‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
1414
import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic';
1515
import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, formatDateTime, getSortValue } from '@object-ui/core';
1616
import type { DataTableSchema, TableSortItem, TableColumnType } from '@object-ui/types';
17-
import { SchemaRenderer, useRowPredicate, usePredicateScope } from '@object-ui/react';
17+
import { SchemaRenderer, toRenderableSchema, useRowPredicate, usePredicateScope } from '@object-ui/react';
1818
import { createSafeTranslation } from '@object-ui/i18n';
1919
import {
2020
Table,
@@ -2156,10 +2156,48 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
21562156
`type` is missing or unregistered now gets the
21572157
platform's uniform "unknown component type" report
21582158
instead of rendering as silent nothing here — one
2159-
answer for malformed metadata, not a private one. */}
2160-
{schema.emptyAction && typeof schema.emptyAction === 'object' && (
2161-
<SchemaRenderer schema={schema.emptyAction} />
2162-
)}
2159+
answer for malformed metadata, not a private one.
2160+
2161+
No object-only guard either (objectui#8331), ruled one
2162+
slot over together with the declaration: objectui#7105
2163+
(director seat, decision batch #69, 2026-09-07) settled
2164+
the identical shape on `EmptySchema.action` as RELAX THE
2165+
RENDERER, do not narrow the declaration. `emptyAction`
2166+
is declared `SchemaNode` on BOTH published faces, and a
2167+
`typeof === 'object'` test made this slot narrower than
2168+
the thing it declares: a bare string was silently
2169+
DROPPED instead of rendering as its own text.
2170+
2171+
The truthiness leg STAYS and the `&&` chain became a
2172+
ternary. Both are load-bearing, and together they make
2173+
this slot behave exactly as handing the raw node to
2174+
`SchemaRenderer` would - the "one answer, not a private
2175+
one" rule above, extended to the non-object members of
2176+
the union:
2177+
2178+
- `toRenderableSchema` is the repo's permanent bridge
2179+
onto `SchemaRendererProps['schema']`, which declares
2180+
no `number` / `boolean` (objectui#4548 ruling Q2). It
2181+
maps those two onto their `String` form, which is
2182+
behaviour-preserving for the TRUTHY ones - that is
2183+
what the renderer's own defensive branch produces -
2184+
but NOT for `0` / `false`, which `SchemaRenderer`
2185+
renders as nothing (pinned, objectui#4548). Gating on
2186+
truthiness keeps those two away from the bridge, so
2187+
they keep the platform's answer instead of arriving as
2188+
the text "0" and "false". Do not "tidy" the leg into a
2189+
nullish test.
2190+
- The ternary replaces an `&&` chain that LEAKED: with
2191+
`emptyAction: 0` the chain evaluated to the number `0`
2192+
itself, which React renders as a stray "0" inside the
2193+
empty state. That is the numeric-falsy JSX trap, not a
2194+
decision; a ternary yields `null` instead.
2195+
2196+
Both legs are pinned in
2197+
`__tests__/data-table-empty-action-primitive-node.test.tsx`. */}
2198+
{schema.emptyAction ? (
2199+
<SchemaRenderer schema={toRenderableSchema(schema.emptyAction)} />
2200+
) : null}
21632201
</div>
21642202
</TableCell>
21652203
</TableRow>

0 commit comments

Comments
 (0)