Skip to content

fix(react): NavigationConfig.mode is optional — the type says what the hook does (#4550) - #4586

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4550-navigation-config-mode
Aug 13, 2026
Merged

fix(react): NavigationConfig.mode is optional — the type says what the hook does (#4550)#4586
yinlianghui merged 1 commit into
mainfrom
claude/issue-4550-navigation-config-mode

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4550

The mismatch, measured

@object-ui/react published a NavigationConfig that required mode, in front of a useNavigationOverlay that has always defaulted it.

// packages/react/src/hooks/useNavigationOverlay.ts:48 (before)
export type NavigationConfig = Omit< SpecAuthoredInput< typeof NavigationConfigSchema >, 'mode' > & {
  mode: NonNullable< SpecAuthoredInput< typeof NavigationConfigSchema >['mode'] >;
};

// ...140 lines below, in the hook it fronts:
const mode: NavigationMode = navigation?.mode ?? 'page';

The type was strictly stricter than the implementation it fronts, and 'page' is meaningful behaviour rather than a placeholder.

The spec never asked for that. packages/spec/src/ui/view.zod.ts:1210:

mode: NavigationModeSchema.default('page'),

A .default() lands on the authoring side as | undefined, so navigation: { view: 'summary_view' } is legal authored metadata that lets the mode default. The spec publishes exactly that as its own type (view.zod.ts:3466, z.input< typeof NavigationConfigSchema >).

Corroboration: @object-ui/types (packages/types/src/index.ts:1087) already re-exports the spec's NavigationConfig unchanged. So this monorepo shipped two published types of the same name that disagreed about whether mode could be omitted — and the react one was the odd one out.

Writer / reader census (repo-wide)

Writers — 12 sites construct a value passed as navigation. Only one carried an assertion caused by this defect:

Site Expression Disposition
plugin-list/src/ListView.tsx:1738 schema.navigation as NavigationConfig | undefined assertion deleted — this PR
plugin-grid/src/ObjectGrid.tsx:1032, plugin-list/src/ObjectGallery.tsx:236 schema.navigation already compiled; unchanged
app-shell InterfaceListPage:240, ObjectDataPage:283, ObjectView:1525 literal / ViewNavigationConfig, explicit mode unchanged
map:561, tree:379, timeline:413, gantt:1150, calendar:398 (schema as any).navigation untouched — a different defect (the props type lacks navigation at all), not this alias

Readers — sites that destructure .mode:

  • useNavigationOverlay itself — navigation?.mode ?? 'page', already handles undefined.
  • ObjectGrid.tsx:3301 — reads the hook's result (NavigationOverlayState.mode), always defined. Unaffected.
  • calendar:396 / gantt:1148 / kanban:536 — navConfig.mode === 'drawer' | ... on any-typed values. Undefined-mode already yields false (non-overlay), which agrees with the hook's 'page' default (also non-overlay).
  • plugin-view/src/ObjectView.tsx:542+ — a different alias (ViewNavigationConfig from @object-ui/types), guarded by if (navigationConfig).

So ruling item 2's "fix any reader that inherits the default" is a measured no-op: no reader of this alias reads .mode unguarded. Reported as a measurement rather than manufactured into edits.

Red-first

A spec-authored config without mode, assigned to the alias — verbatim tsc output before the change:

error TS2322: Type '{ mode?: "none" | "split" | "page" | ... | undefined; view?: string | undefined; ... }'
  is not assignable to type 'NavigationConfig'.
    Types of property 'mode' are incompatible.
      Type '"none" | "split" | "page" | ... | undefined' is not assignable to type 'NonNullable< ... >'.
        Type 'undefined' is not assignable to type 'NonNullable< ... >'.

error TS2322: Type '{}' is not assignable to type 'NavigationConfig'.
  Property 'mode' is missing in type '{}' but required in type '{ mode: NonNullable< ... >; }'.

9 errors total across the two test files. After the change: tsc --noEmit && tsc -p tsconfig.test.json both clean.

What changed

export type NavigationConfig = SpecAuthoredInput< typeof NavigationConfigSchema >;

That, the doc comments, the ListView assertion removal, three inverted pins, and a new behaviour suite. The hook body is untouched.

Three pins in offline-nav-performance-spec-parity.test.ts encoded the defect and are inverted: _ModeIsRequired becomes _ModeIsOptional, _StillNarrowed becomes _SpecShapedValueFits, and a new _IsExactlyTheSpecInput pins the collapse. That file's own comment predicted this ("if the spec ever makes mode required itself ... this alias should collapse to SpecAuthoredInput< typeof NavigationConfigSchema >") — the collapse arrived from the other direction, since the spec never moved and the alias was wrong all along.

One pin needed care rather than inversion: _ModeIsConfigMode compared NavigationMode to NavigationConfig['mode'], which now carries | undefined. A bare equality would fail for a reason unrelated to the drift it guards, so it compares against NonNullable< NavigationConfig['mode'] > — the membership of the seven modes stays pinned, which is what that test is for.

Reverse verification — direction stated up front

This is a type-only change, so the honest direction is a tsc red, not a suite red. Both were run:

  • Fix removed (patch file + git checkout --, never git stash): tsc -p tsconfig.test.json9 errors (exit 2). vitest on the new suite → 16/16 still green, because the runtime is untouched. Reporting a vitest red here would have been a fabrication.
  • Restore verified byte-exact by sha256 on all four touched files.
  • To prove the new runtime pins are not vacuous, the default was then mutated (?? 'page' to ?? 'drawer'): 4 of 16 failed, with expected 'drawer' to be 'page' and expected [] to deeply equal [ [ 'r1', 'view' ] ]. The other 12 stayed green — correct, since they pin explicit modes that do not consult the default. Restored and sha256-verified again.

Verification

  • pnpm --filter '@object-ui/react^...' --filter '@object-ui/react' build — green (dependency closure first).
  • pnpm --filter @object-ui/react type-check — green, both passes (tsc --noEmit and tsc -p tsconfig.test.json).
  • pnpm exec vitest run --maxWorkers=2 packages/react43 files, 598 tests passed; the new suite is 16 of them, confirmed running in the dom project.
  • pnpm exec vitest run --maxWorkers=2 packages/plugin-list packages/plugin-grid103 files, 1144 tests passed.
  • Consumer sweep, prefix direction (...@object-ui/react = downstream consumers, after a full 43-task build so no stale artifacts): 31 packages green, including apps/console, app-shell, all nine hook-consuming plugins and three examples.
  • ESLint vs the merge-base 3f5f87cc7 in a comparison worktree: NET ZERO — react 350/350, plugin-list 417/417, 0 errors both sides.
  • check:control-bytes, check:phantom-deps, changeset:check, check:spec-symbols, check-changeset-presence — all green. grep -naP control-byte self-scan over all four touched files: clean.

Changeset grade: minor for @object-ui/react

Optional-izing is looser for writers and narrower for readers, so the grade turns on which role the published surface plays. Measured from the .d.ts: NavigationConfig occurs only in input positionsuseNavigationOverlay's navigation?: option and resolveOverlayWidth's parameter — and never in a return type. The package consumes these values and never hands one back, so for consumers the change is purely permissive: everything that compiled still compiles, and spec-shaped configs that needed an assertion now compile without one. That gained input shape is a capability rather than an internal repair, which is more than patch describes. The reader-side narrowing is real but secondary, and the in-repo census found exactly one type-importer — ListView — which imported it only to write the assertion this PR deletes.

The only type-level line in the whole .d.ts diff is the alias; everything else is doc comment.

Surface

packages/react (hook + 2 test files), packages/plugin-list/src/ListView.tsx (the censused caller), one changeset. Untouched as instructed: SchemaRenderer.tsx / schema-input.ts (landed #4578), apps/console (#4563), types + ObjectGrid exportOptions (#4535), SettingsView (#4570), plugin-report / previews (#4575), content/docs/releases/.


Generated by Claude Code

…e hook does (#4550)

`@object-ui/react` published a `NavigationConfig` that required `mode`, in
front of a `useNavigationOverlay` that has always defaulted it. The alias
`Omit`ted `mode` from the spec's authored config and re-added it as
`NonNullable< … >`; ~140 lines below, the hook read
`navigation?.mode ?? 'page'`. The type was strictly stricter than the
implementation it fronts, and 'page' is meaningful behaviour, not a
placeholder.

The spec never asked for that: `NavigationConfigSchema` declares
`mode: NavigationModeSchema.default('page')` (packages/spec/src/ui/view.zod.ts),
and a `.default()` lands on the authoring side as `| undefined`.
`@object-ui/types` already re-exported the spec's own `NavigationConfig`
unchanged — so one monorepo shipped two published types of the same name that
disagreed about whether `mode` could be omitted.

The alias is now the spec's authored config verbatim, with no divergence of
its own. `ListView` carried `schema.navigation as NavigationConfig | undefined`
purely to get a valid spec-shaped value past the old declaration; that
assertion is deleted rather than replaced.

Nothing changes at runtime: `navigation?.mode ?? 'page'` is untouched. The
default is now pinned as observable behaviour alongside every explicit mode,
the `none` / `preventNavigation` short-circuits, the `onRowClick` priority and
the Cmd/Ctrl/middle-click and `new_window` branches.

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:09pm

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

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

Projects

None yet

2 participants