Skip to content

Commit a84385b

Browse files
yinlianghuiclaude
andauthored
fix(react): NavigationConfig.mode is optional — the type says what the hook does (#4550) (#4586)
`@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. Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f9b905 commit a84385b

5 files changed

Lines changed: 450 additions & 40 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/react': minor
3+
'@object-ui/plugin-list': patch
4+
---
5+
6+
`NavigationConfig.mode` is optional — the type now says what the hook does
7+
8+
`@object-ui/react` published a `NavigationConfig` that required `mode`, in front of a `useNavigationOverlay` that has always defaulted it. The declaration took the spec's authored config, `Omit`ted `mode`, and re-added it as `NonNullable< … >`; 140 lines below, the hook read `navigation?.mode ?? 'page'`. The type was strictly stricter than the implementation it fronted, and `'page'` is meaningful behaviour rather than a placeholder.
9+
10+
The spec never asked for that. `NavigationConfigSchema` declares `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the authoring side as `| undefined` — so `navigation: { view: 'summary_view' }` is legal authored metadata that lets the mode default. `@object-ui/types` already re-exported the spec's own `NavigationConfig` unchanged, which meant one monorepo shipped two published types of the same name that disagreed about whether `mode` could be omitted.
11+
12+
The alias is now the spec's authored config verbatim, with no divergence of its own:
13+
14+
```ts
15+
export type NavigationConfig = SpecAuthoredInput< typeof NavigationConfigSchema >;
16+
```
17+
18+
The cost of the old spelling was paid by callers. `ListView` carried `schema.navigation as NavigationConfig | undefined` for no reason except to get a valid spec-shaped value past the declaration; that assertion is deleted here, not replaced. A type in front of an implementation must not be stricter than the implementation — when it is, every caller pays in casts, and a cast is exactly the renderer-side workaround that belongs back at the producer.
19+
20+
**Nothing changes at runtime.** `navigation?.mode ?? 'page'` is untouched, and the default is now pinned as observable behaviour (`useNavigationOverlay.modeDefault.test.tsx`) rather than only as a comment — the explicit modes, the `preventNavigation` and `none` short-circuits, the `onRowClick` priority, and the Cmd/Ctrl/middle-click and `new_window` branches are all pinned alongside it.
21+
22+
**Why minor rather than patch**, from the measured `.d.ts`. Optional-izing a property is looser for writers and narrower for readers, so the grade turns on which role the published surface actually plays. In this package `NavigationConfig` occurs only in input positions — `useNavigationOverlay`'s `navigation?:` option and `resolveOverlayWidth`'s parameter — and never in a return type; the package consumes these values and never hands one back. For consumers the change is therefore purely permissive: every call that compiled before still compiles, and spec-shaped configs that previously needed an assertion now compile without one. That gained input shape is a real capability rather than an internal repair, which is more than a patch describes. The reader-side narrowing is real but secondary: code that imports the bare type, annotates its own value with it and reads `.mode` now sees `NavigationMode | undefined`. The in-repo census found exactly one such importer — `ListView` — and it imported the type only to write the assertion this change removes.

packages/plugin-list/src/ListView.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher';
1515
import { ViewSettingsPopover } from './components/ViewSettingsPopover';
1616
import { UserFilters } from './UserFilters';
1717
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
18-
import type { NavigationConfig } from '@object-ui/react';
1918
import { useDensityMode } from '@object-ui/react';
2019
import type { ListViewSchema } from '@object-ui/types';
2120
import { detectStatusField } from '@object-ui/types';
@@ -1727,15 +1726,21 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
17271726
}, [onSearchChange]);
17281727

17291728
// --- NavigationConfig support ---
1730-
// The assertion bridges two spellings of ONE spec object and changes no
1731-
// value: `@object-ui/react`'s `NavigationConfig` alias re-declares `mode` as
1732-
// NON-optional, while the spec-derived `ListViewSchema['navigation']` leaves
1733-
// it optional. The hook's own body defaults it (`navigation?.mode ?? 'page'`),
1734-
// so a spec-shaped value is valid input and only the alias is tighter than
1735-
// its implementation. Surfaced by objectui#4528: this call used to type-check
1736-
// for the wrong reason, because the erased props type made `schema` `any`.
1729+
// No assertion, deliberately. `schema.navigation` is the spec-derived
1730+
// `ListViewSchema['navigation']` and the hook's `NavigationConfig` is now the
1731+
// spec's authored config verbatim — `mode` optional and all (objectui#4550).
1732+
// Two spellings of one spec object, so they simply agree.
1733+
//
1734+
// This call carried `as NavigationConfig | undefined` from objectui#4528
1735+
// until then. That cast bridged nothing real: the alias re-declared `mode` as
1736+
// required while the hook it fronts defaults it (`navigation?.mode ?? 'page'`),
1737+
// so the cast's only job was to get a valid value past an over-tight type.
1738+
// objectui#4550 fixed that at the producer, which deleted the reason for the
1739+
// cast — and a cast kept past its reason is how the next reader learns the
1740+
// wrong thing about the contract. Neither the cast nor its removal touches
1741+
// the runtime value.
17371742
const navigation = useNavigationOverlay({
1738-
navigation: schema.navigation as NavigationConfig | undefined,
1743+
navigation: schema.navigation,
17391744
objectName: schema.objectName,
17401745
onNavigate: schema.onNavigate,
17411746
onRowClick,

packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ describe('OfflineCacheConfig keeps its defaulted keys authorable', () => {
233233
});
234234
});
235235

236-
describe('NavigationConfig derives from the spec, requiring only `mode`', () => {
236+
describe('NavigationConfig IS the spec\'s authored navigation config', () => {
237237
it('is pinned at compile time', () => {
238238
type SpecNavigationInput = SpecAuthoredInput<typeof NavigationConfigSchema>;
239239
type _SpecNotAny = Assert<Equal<IsAny<SpecNavigationInput>, false>>;
@@ -247,14 +247,35 @@ describe('NavigationConfig derives from the spec, requiring only `mode`', () =>
247247
type _NoLocalOnlyKeys = Assert<Equal<Exclude<keyof NavigationConfig, keyof SpecNavigationInput>, never>>;
248248
type _NoMissingKeys = Assert<Equal<Exclude<keyof SpecNavigationInput, keyof NavigationConfig>, never>>;
249249

250-
// `mode` is the ONE narrowing, and it is required here. If the spec ever
251-
// makes `mode` required itself, `_StillNarrowed` fails and this alias
252-
// should collapse to `SpecAuthoredInput<typeof NavigationConfigSchema>`.
253-
type _ModeIsRequired = Assert<Equal<undefined extends NavigationConfig['mode'] ? true : false, false>>;
254-
type _StillNarrowed = Assert<Equal<Extends<SpecNavigationInput, NavigationConfig>, false>>;
255-
type _EverythingElseMatches = Assert<
256-
Extends<Omit<SpecNavigationInput, 'mode'>, Omit<NavigationConfig, 'mode'>>
257-
>;
250+
// objectui#4550 — the narrowing is GONE, and these three pins are the
251+
// inverted descendants of the ones that described it.
252+
//
253+
// The alias used to `Omit` `mode` and re-add it as `NonNullable<…>`, which
254+
// made it strictly tighter than BOTH the spec that produces the value and
255+
// the hook that consumes it (`navigation?.mode ?? 'page'`, 140 lines below
256+
// the declaration). A required key in front of an implementation that
257+
// defaults it is not a contract, and the cost was paid at every spec-typed
258+
// caller: `ListView` had to write `schema.navigation as NavigationConfig`
259+
// to hand the hook a value the hook was always happy to take.
260+
//
261+
// The previous spelling of this block predicted its own end — "if the spec
262+
// ever makes `mode` required itself, `_StillNarrowed` fails and this alias
263+
// should collapse to `SpecAuthoredInput<typeof NavigationConfigSchema>`."
264+
// The collapse arrived from the other direction (the spec never moved; the
265+
// alias was wrong all along), but it is the same collapse.
266+
type _IsExactlyTheSpecInput = Assert<Equal<NavigationConfig, SpecNavigationInput>>;
267+
268+
// `mode` is OPTIONAL to author, because the spec defaults it
269+
// (`packages/spec/src/ui/view.zod.ts` → `mode: NavigationModeSchema.default('page')`)
270+
// and a `.default()` lands on the AUTHORING side as `| undefined`. This is
271+
// the assertion that was false before objectui#4550.
272+
type _ModeIsOptional = Assert<Equal<undefined extends NavigationConfig['mode'] ? true : false, true>>;
273+
274+
// Assignability now runs BOTH ways. `_LocalIsASpecConfig` above is the one
275+
// direction that always held; this is the one the narrowing blocked, and
276+
// it is the direction every caller actually needs — spec produces, alias
277+
// consumes.
278+
type _SpecShapedValueFits = Assert<Extends<SpecNavigationInput, NavigationConfig>>;
258279

259280
// The overlay buckets the hook maps to pixel widths are the spec's, and
260281
// `size` is the key #2578 added — the deprecated `width` is still here too.
@@ -265,7 +286,7 @@ describe('NavigationConfig derives from the spec, requiring only `mode`', () =>
265286
});
266287

267288
it('still carries every overlay mode the hook switches on', () => {
268-
const all: NavigationConfig['mode'][] = [
289+
const all: NonNullable<NavigationConfig['mode']>[] = [
269290
'page',
270291
'drawer',
271292
'modal',
@@ -284,19 +305,25 @@ describe('NavigationConfig derives from the spec, requiring only `mode`', () =>
284305
// spec's `NavigationMode` directly, and this is the pin the declaration
285306
// promises: the two spellings must stay the SAME type.
286307
//
287-
// They can come apart in a way nothing else would catch. The equality holds
288-
// today only because `NavigationConfig` above strips the `undefined` that
289-
// `NavigationConfigSchema`'s `.default('page')` puts on `mode`'s authoring
290-
// side. If the spec ever stops defaulting `mode`, or defaults it on a
291-
// narrower union, `NavigationConfig['mode']` moves and the exported alias
292-
// does not — and every call site keeps compiling, because the hook's
308+
// They can come apart in a way nothing else would catch. If the spec ever
309+
// stops defaulting `mode`, or defaults it on a narrower union,
310+
// `NavigationConfig['mode']` moves and the exported alias does not — and
311+
// every call site keeps compiling, because the hook's
293312
// `navigation?.mode ?? 'page'` would still be assignable either way.
294313
//
295314
// Both directions, deliberately: a one-way `extends` is satisfied by a
296315
// narrowing as well as by equality, and a narrowing is exactly the drift
297316
// that would delete a mode the hook switches on.
317+
//
318+
// `NonNullable` is objectui#4550's mark on this pin, and it is load-bearing
319+
// rather than cosmetic. The alias no longer strips the `undefined` that
320+
// `.default('page')` puts on the authoring side, so `NavigationConfig['mode']`
321+
// is now `NavigationMode | undefined` — a bare `Equal` here would fail for
322+
// a reason that has nothing to do with the drift this test exists to catch.
323+
// What must stay pinned is the MEMBERSHIP: the seven modes the hook
324+
// switches on are exactly the seven the exported union publishes.
298325
type _ModeIsSpecMode = Assert<Equal<NavigationMode, SpecNavigationMode>>;
299-
type _ModeIsConfigMode = Assert<Equal<NavigationMode, NavigationConfig['mode']>>;
326+
type _ModeIsConfigMode = Assert<Equal<NavigationMode, NonNullable<NavigationConfig['mode']>>>;
300327

301328
// Runtime half — the type assertions above are erased, so this is what
302329
// fails visibly if the union ever loses a member.

0 commit comments

Comments
 (0)