Found while landing objectui#4535 (PR #4584). Out of that card's surface — filed rather than fixed there.
What happens
packages/react/src/spec-bridge/bridges/list-view.ts builds a renderer node of type: 'object-grid' (:125) and copies the spec's export options across verbatim:
if (spec.exportOptions) node.exportOptions = spec.exportOptions; // :158
That node is rendered by ObjectGridRenderer (ComponentRegistry.register('object-grid', …)), i.e. by ObjectGrid. ObjectGrid reads the OBJECT form and only that:
const declared = schema.exportOptions?.formats || ['csv', 'json']; // ObjectGrid.tsx:1697
At objectui's pinned @objectstack/spec@17.0.0-rc.6, ListView.exportOptions is a bare format ARRAY — ('csv' | 'xlsx' | 'json' | 'pdf')[] — not an object. So .formats on it is undefined, the default ['csv', 'json'] wins, and the view's declared formats are dropped with no error, no warning and no console line.
Net effect for an author: a spec-canonical list view declaring exportOptions: ['csv', 'xlsx'] and routed through SpecBridge renders an export menu offering csv and json. The declared xlsx never appears; an undeclared json does. !!schema.exportOptions is still truthy (a non-empty array), so the export button itself shows — the failure is silent rather than absent.
Why the spec's array lift does not save it
objectstack#8010 gave ListViewExportOptionsSchema a parse-time lift: a stored bare array becomes { formats: [...] } when the spec schema parses it. That lift never runs on this path. The bridge's input is a TypeScript type — type ListViewSpec = Partial< ListView > — not a parsed value, and there is no parse or safeParse anywhere under packages/react/src/spec-bridge/. So the bridge forwards whatever its host handed it. A host that parses first is fine; a host that passes raw metadata is not, and nothing in the bridge distinguishes them.
This is why bumping the spec pin alone will not close it.
Pinned in the current tests, so it reads as intended
packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts:390-397 asserts the passthrough:
it('should pass through exportOptions string[] format', () => {
// …
expect(node.exportOptions).toEqual(['csv', 'xlsx']);
});
The assertion is about the bridge's output shape and never renders it, so the bridge is green while the grid downstream cannot read what it produced.
Repro
- Build a spec
ListView with exportOptions: ['csv', 'xlsx'].
- Run it through
SpecBridge and render the resulting node.
- Open the export menu: it offers CSV and JSON. Expected: CSV and XLSX (XLSX subject to the server-stream gate).
Direction (for triage, not a decision)
The producer is where this belongs — a consumer-side Array.isArray fallback in ObjectGrid would be a second de-facto contract for one spec key, which is the shape objectstack#8010 was filed against. Two candidates worth weighing:
- Lift in the bridge: normalize a bare array to
{ formats: [...] } at :158, mirroring the spec's own parse-time lift, so the bridge emits one shape regardless of what the host hands it. Cheap, local, and keeps the renderer strict.
- Parse in the bridge: run the spec schema over the input so the lift and every other spec-side coercion apply. Larger change and a behavior change for hosts currently passing fragments (the input is deliberately
Partial).
ListView (plugin-list) already normalizes both spellings for its own toolbar (ListView.tsx:1104-1113), so whichever lands, the bridge path is the one that lacks it.
Related
Generated by Claude Code
Found while landing objectui#4535 (PR #4584). Out of that card's surface — filed rather than fixed there.
What happens
packages/react/src/spec-bridge/bridges/list-view.tsbuilds a renderer node oftype: 'object-grid'(:125) and copies the spec's export options across verbatim:That node is rendered by
ObjectGridRenderer(ComponentRegistry.register('object-grid', …)), i.e. byObjectGrid.ObjectGridreads the OBJECT form and only that:At objectui's pinned
@objectstack/spec@17.0.0-rc.6,ListView.exportOptionsis a bare format ARRAY —('csv' | 'xlsx' | 'json' | 'pdf')[]— not an object. So.formatson it isundefined, the default['csv', 'json']wins, and the view's declared formats are dropped with no error, no warning and no console line.Net effect for an author: a spec-canonical list view declaring
exportOptions: ['csv', 'xlsx']and routed throughSpecBridgerenders an export menu offering csv and json. The declaredxlsxnever appears; an undeclaredjsondoes.!!schema.exportOptionsis still truthy (a non-empty array), so the export button itself shows — the failure is silent rather than absent.Why the spec's array lift does not save it
objectstack#8010 gave
ListViewExportOptionsSchemaa parse-time lift: a stored bare array becomes{ formats: [...] }when the spec schema parses it. That lift never runs on this path. The bridge's input is a TypeScript type —type ListViewSpec = Partial< ListView >— not a parsed value, and there is noparseorsafeParseanywhere underpackages/react/src/spec-bridge/. So the bridge forwards whatever its host handed it. A host that parses first is fine; a host that passes raw metadata is not, and nothing in the bridge distinguishes them.This is why bumping the spec pin alone will not close it.
Pinned in the current tests, so it reads as intended
packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts:390-397asserts the passthrough:The assertion is about the bridge's output shape and never renders it, so the bridge is green while the grid downstream cannot read what it produced.
Repro
ListViewwithexportOptions: ['csv', 'xlsx'].SpecBridgeand render the resulting node.Direction (for triage, not a decision)
The producer is where this belongs — a consumer-side
Array.isArrayfallback inObjectGridwould be a second de-facto contract for one spec key, which is the shape objectstack#8010 was filed against. Two candidates worth weighing:{ formats: [...] }at :158, mirroring the spec's own parse-time lift, so the bridge emits one shape regardless of what the host hands it. Cheap, local, and keeps the renderer strict.Partial).ListView(plugin-list) already normalizes both spellings for its own toolbar (ListView.tsx:1104-1113), so whichever lands, the bridge path is the one that lacks it.Related
Generated by Claude Code