Skip to content

SpecBridge hands ObjectGrid a bare exportOptions array, so a spec-authored view's declared formats are silently ignored #4585

Description

@yinlianghui

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

  1. Build a spec ListView with exportOptions: ['csv', 'xlsx'].
  2. Run it through SpecBridge and render the resulting node.
  3. 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

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4585-bridge-export-lift. Dispatching a dev agent now.

    PM ruling (delegated decision authority; maintainer veto window open — record objections here): lift in the bridge, not parse-in-the-bridge:

    1. The bridge normalizes a bare exportOptions array to { formats: [...] } at the assignment site, mirroring EXACTLY the spec's own parse-time lift and nothing more — this is the SAME contract applied where parse cannot reach, not a second one (the consumer-side fallback the card rightly rejects would be). The object form passes through verbatim (pinned unchanged). Full-parse is rejected: the input is deliberately Partial, and running the spec schema over host fragments is a behavior change out of proportion to one key's coercion.
    2. Type the lifted output as the ListViewExportOptions PR fix(types): exportOptions matches the spec's object form — pdf retired, streaming typed (#4535) #4584 landed in @object-ui/types — one spelling of the five-key shape, no third copy.
    3. Red-first at BOTH levels: bridge-output (node.exportOptions currently equals the bare array — the P1SpecBridge passthrough pin is an AUTHORIZED pin move: it pins the broken passthrough today and moves to pin the lifted shape, declared in pins_moved) and end-to-end (the card's repro — a spec view declaring ['csv','xlsx'] rendered through the bridge offers csv+json today; post-fix csv with xlsx subject to the stream gate — reuse ObjectGrid's exportGate harness idiom).
    4. Residue noted, not built: when the spec pin bumps past fix(combobox): honour description, retire defaultValue, pin the name delivery #8324, an equivalence test (bridge lift === spec parse output) becomes possible — record the intent in the test header; do not block on it.
    5. Changeset: '@object-ui/react' per its measured .d.ts (the bridge is internal — expected patch; if the node type surface moves, grade with reasoning). Never major.

    Sequencing: gate worktree creation on PR #4584 reaching origin/main (armed on green CI — the type you consume lands there). Mutual exclusion: ⛔ #4550 in flight owns packages/react's navigation files (disjoint — spec-bridge only for you); #4576 in flight owns core/i18n number policy (disjoint). ObjectGrid.tsx is read-only landed context.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions