Skip to content

fix(react): SpecBridge lifts a bare exportOptions array to the spec's object form (#4585) - #4587

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4585-bridge-export-lift
Aug 13, 2026
Merged

fix(react): SpecBridge lifts a bare exportOptions array to the spec's object form (#4585)#4587
yinlianghui merged 1 commit into
mainfrom
claude/issue-4585-bridge-export-lift

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4585

What was wrong

packages/react/src/spec-bridge/bridges/list-view.ts copied the spec's export options onto the object-grid node verbatim:

if (spec.exportOptions) node.exportOptions = spec.exportOptions;   // :158

ObjectGrid reads the OBJECT form and only that (schema.exportOptions?.formats). .formats on a bare array is undefined, so the renderer's ['csv', 'json'] default won. A spec-canonical view declaring exportOptions: ['csv', 'xlsx'] and routed through SpecBridge therefore rendered an export menu offering csv and json — the declared xlsx never appeared, an undeclared json did, and nothing said so. !!schema.exportOptions stayed truthy for a non-empty array, so the export button still showed: the failure was silent, not absent.

The spec's own parse-time lift (objectstack#8010) never rescued this path. The bridge's input is a TypeScript type — Partial< ListView > — not a parsed value, and there is no parse/safeParse anywhere under spec-bridge/. A host that parses first hands over the object form; a host that forwards raw stored metadata hands over whatever was authored, and nothing in the bridge can tell them apart. That is why bumping the spec pin alone would not have closed this.

The fix — the spec's lift, mirrored where parse cannot reach

The bridge now applies the spec's OWN transform at the assignment site (@objectstack/spec ui/view.zod.ts, objectstack#8010):

z.array(ListViewExportFormatSchema).transform((formats) => ({ formats }))

This is one contract applied one layer out, not a second de-facto one. The consumer-side Array.isArray fallback in ObjectGrid is what AGENTS.md #0.1 forbids, and the card rejected it for the same reason. Full-parse-in-the-bridge was rejected by the PM ruling: 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.

The lifted value is typed ListViewExportOptions from @object-ui/types (landed in #4584) — one spelling of the spec's five-key shape, no third copy. The bridge's public .d.ts is unchanged.

Deliberately narrow, each half pinned:

  • object form passes through by reference, unread and unrewritten (pinned with toBe, not toEqual);
  • empty array lifts to { formats: [] } verbatim — measured, not assumed: the spec's z.array() carries no .min(1), so [] is a legal input that wraps rather than defaults;
  • no exportOptions leaves the key absent, unchanged;
  • a 'pdf' stored before its retirement is carried, not filtered. The spec REFUSES 'pdf' at parse with a migration prescription (objectstack#8010; PDF export declined as objectstack#1301 NOT_PLANNED) — it does not silently drop the value, so the mirror of its lift may not either. Such a format dies downstream in ObjectGrid's format-agnostic menu filter, kept deliberately at Reconcile exportOptions with the spec's new object form (#8010 spec half): false alignment comment, local 'pdf', and the as any streaming read #4535 for metadata predating os migrate meta --from 16.

One behavior change, called out

exportOptions: [] now lifts to { formats: [] }, which ObjectGrid reads literally: no format is offered, exportableFormats.length > 0 fails, and the export button is hidden. Before, the unreadable [] was merely truthy, so the button showed and offered a menu built entirely out of the ['csv', 'json'] default. A view that declares zero formats now offers zero. This follows from reading the spec's lift literally rather than from any extra rule here; it is pinned end to end.

Red-first, both levels, predictions written first

Predictions were recorded before any run; the red lap took the fix out with git checkout origin/main -- ... (never git stash) and restored it sha256-verified. @object-ui/react aliases to src in the vitest config, so the revert is visible at both levels. 7 failures predicted, 7 observed, no others.

Level (a) — bridge output:

AssertionError: expected [ 'csv', 'xlsx' ] to deeply equal { formats: [ 'csv', 'xlsx' ] }
AssertionError: expected [] to deeply equal { formats: [] }
AssertionError: expected [ 'csv', 'pdf' ] to deeply equal { formats: [ 'csv', 'pdf' ] }

Level (b) — end to end through ObjectGrid:

TestingLibraryElementError: Unable to find role="button" and name `/export as xlsx/i`
Error: expect(element).not.toBeInTheDocument()
       expected document not to contain element, found (button) Export as JSON

The DOM dump of the failing render contains the undeclared Export as JSON item — the card's mechanism, observed rather than inferred.

The two must-not-change pins (object-form passthrough, view with no exportOptions) were green on both sides of the revert, which is what makes the red meaningful.

Authorized pin move

P1SpecBridge.test.ts should pass through exportOptions string[] format pinned the bare array reaching the node verbatim — the one shape ObjectGrid cannot read. It asserted the bridge's output and never rendered it, so the bridge stayed green while the grid downstream could not read what it produced. It moves to pin the lifted shape and is renamed accordingly. This move is authorized by the PM ruling on #4585.

Residue, recorded not built

objectui pins @objectstack/spec@17.0.0-rc.6, where ListView.exportOptions is still the bare array and the lift does not exist yet (measured in the pinned dist). Once the pin bumps past objectstack#8324, a stronger assertion replaces the hand-mirroring: parse the same input through the spec and require bridge lift === spec parse output — one contract proven equal rather than copied. The intent is recorded in the new test file's header; nothing blocks on it.

Verification


Generated by Claude Code

… object form (#4585)

The bridge copied `spec.exportOptions` onto the `object-grid` node verbatim, so a
legacy bare format array reached ObjectGrid — which reads the object form and only
that. `.formats` on an array is `undefined`, so the renderer's `['csv', 'json']`
default won and the view's declared formats were dropped silently: a view declaring
`['csv', 'xlsx']` rendered an export menu offering CSV and JSON.

The bridge now mirrors the spec's own parse-time lift (objectstack#8010) at the
assignment site — the same contract applied where `parse` cannot reach, not a second
one. The object form passes through by reference; an empty array lifts to
`{ formats: [] }` as the spec's transform does; a pre-retirement `'pdf'` is carried
rather than filtered, since the spec refuses that value instead of dropping it.

The P1SpecBridge array pin moves with the fix: it pinned the broken passthrough and
now pins the lifted shape. New coverage at both levels — the bridge's output shape,
and the rendered export menu end to end through ObjectGrid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 2:12pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BCoyAl1j.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.33KB 108.47KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 14:24
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit f148a64 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4585-bridge-export-lift branch August 13, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants