From 9d1a0f582a31778cc3beff7e1704c4b1a52642b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:57:43 +0000 Subject: [PATCH] fix(mobile): SpecResponsiveConfig derives from its claim's source (#4598) `useResponsiveConfig.ts` hand-declared an interface over the schema's four responsive keys, renamed off the schema's own symbol (`ResponsiveConfig` -> `SpecResponsiveConfig`), under a comment saying it mirrored `ResponsiveConfigSchema`. No import, no `z.infer`, no compile-time tie of any kind: the sentence was the whole connection. It agreed key-for-key on the day it was measured, which is the reason to fix it rather than the reason to leave it: the agreement was maintained by nobody and checked by nothing, while the comment told every later reader the copy was canonical. `ViewNavigationConfig` (#4588) read like this until it drifted on `mode`. Measured before choosing: `@object-ui/mobile` has no runtime dependency on `@objectstack/spec` (devDependency only), but `@object-ui/types` -- already its only runtime dependency -- publishes the schema's own type under this exact name, imported from `@objectstack/spec/ui`. So the type is re-exported through that existing edge; no new dependency, published name unchanged, and the entry `.d.ts` is byte-identical. A parity test pins the chain to `@objectstack/spec/ui` directly, because `@object-ui/types` re-growing a hand copy is the one link the re-export cannot see. The `CLAIM_DEBT` ledger entry is deleted -- ratchet #5 fails on it while it is stale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../mobile-responsive-config-derives-4598.md | 15 ++ .../responsive-config-spec-parity.test.ts | 168 ++++++++++++++++++ packages/mobile/src/useResponsiveConfig.ts | 35 ++-- scripts/check-spec-symbol-derivation.mjs | 1 - 4 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 .changeset/mobile-responsive-config-derives-4598.md create mode 100644 packages/mobile/src/__tests__/responsive-config-spec-parity.test.ts diff --git a/.changeset/mobile-responsive-config-derives-4598.md b/.changeset/mobile-responsive-config-derives-4598.md new file mode 100644 index 0000000000..a7869237a8 --- /dev/null +++ b/.changeset/mobile-responsive-config-derives-4598.md @@ -0,0 +1,15 @@ +--- +'@object-ui/mobile': patch +--- + +`SpecResponsiveConfig` is now the spec's responsive config rather than a hand copy that said it was + +`useResponsiveConfig.ts` declared its own interface over the four responsive keys — `breakpoint`, `hiddenOn`, `columns`, `order` — renamed off the schema's own symbol (`ResponsiveConfig` → `SpecResponsiveConfig`) and introduced by a comment that said it mirrored `ResponsiveConfigSchema`. There was no import, no `z.infer`, and no other compile-time tie: the sentence was the entire connection. + +It agreed with the schema key-for-key on the day it was measured, and that is the reason this is worth a line rather than the reason it is not. The agreement was maintained by nobody and checked by nothing, while the comment told every later reader the copy was canonical — so a key added or retired upstream would have moved the two apart in silence, with the comment still vouching for the copy. `ViewNavigationConfig` read exactly like this until it had drifted on `mode`. + +The type is now re-exported from `@object-ui/types`, which publishes it imported straight from `@objectstack/spec/ui`. That package is already this one's only runtime dependency, so the binding costs no new dependency edge, and `@object-ui/core`'s `ResponsiveProtocol` already reaches the same type the same way. + +Nothing consumers see changes: the published name is the same, and the type it resolves to is invariant-equal to the interface that was there before — the entry `.d.ts` is byte-identical. What changes is where the four keys come from. They are now whatever the schema declares, so the next schema release reaches this package's authors as a type error instead of as silent disagreement. + +A new parity test pins the chain to `@objectstack/spec/ui` directly, because the one link the re-export cannot see is `@object-ui/types` re-growing a hand copy of its own. diff --git a/packages/mobile/src/__tests__/responsive-config-spec-parity.test.ts b/packages/mobile/src/__tests__/responsive-config-spec-parity.test.ts new file mode 100644 index 0000000000..67dc21fd8f --- /dev/null +++ b/packages/mobile/src/__tests__/responsive-config-spec-parity.test.ts @@ -0,0 +1,168 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `@object-ui/mobile`'s `SpecResponsiveConfig` IS the schema's type (objectui#4598). + * + * The card: `useResponsiveConfig.ts` hand-declared an interface over the four + * responsive keys, renamed off the schema's own symbol (`ResponsiveConfig` → + * `SpecResponsiveConfig`), under a comment saying it mirrored + * `ResponsiveConfigSchema`. Nothing tied the two together — no import, no + * `z.infer`, no compile-time reference of any kind. It AGREED key-for-key when + * it was filed, which is the whole point: the agreement was maintained by + * nobody and checked by nothing, and the comment already told the next session + * the copy was canonical. `ViewNavigationConfig` (objectui#4588) read exactly + * like that until it had drifted on `mode`. + * + * The fix re-exports the type from `@object-ui/types` — this package's only + * runtime dependency, which publishes it imported straight from + * `@objectstack/spec/ui` — so no new dependency edge was needed and the + * published name did not move. + * + * ── Why this file exists, given the fix is a re-export ────────────────────── + * + * `scripts/check-spec-symbol-derivation.mjs` stops reporting the declaration + * because after the fix there is no declaration: rule 2 collects type aliases, + * interfaces, enums and variables, and a bare `export type { … }` is none of + * those. That is the right outcome — the hand copy is gone — but the gate got + * there by seeing nothing, not by following the chain. It cannot: the binding + * now runs mobile → `@object-ui/types` → `@objectstack/spec/ui`, and the gate + * reads one package at a time. + * + * So the middle link is the part no gate covers, and it is the only place this + * fix can still rot: if `@object-ui/types` ever replaces its re-export with a + * hand copy of its own, mobile inherits the copy silently and every check in + * the repo stays green. The assertions below are written against + * `@objectstack/spec/ui` DIRECTLY for that reason — pinning against + * `@object-ui/types` would compare the import to itself and pass no matter what + * either package did. + * + * The type-level half runs under `tsc -p tsconfig.test.json` (this package's + * `type-check`), so a re-grown copy fails the build rather than waiting for a + * reviewer. `@objectstack/spec` is a devDependency here — a test may import it; + * the published `.d.ts` may not, which is why the source re-exports through + * `@object-ui/types` instead of reaching for the spec directly. + */ + +import { describe, it, expect } from 'vitest'; +import { ResponsiveConfigSchema, type ResponsiveConfig } from '@objectstack/spec/ui'; +import type { SpecResponsiveConfig } from '../index'; + +/* eslint-disable @typescript-eslint/no-unused-vars -- + * The pins below are compile-time assertions: `tsc` is their only consumer and + * they are erased before anything runs, so "unused" is what a passing one looks + * like. The repo's `no-unused-vars` ignores `^_` for ARGUMENTS only, which is + * why `packages/types`' equivalent file carries the same warnings; scoping the + * exemption to this file keeps it from becoming a repo-wide hole. + */ + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** + * Invariant equality. `extends` in one direction — or a `satisfies` check — + * would accept a NARROWING, so a copy that quietly dropped `order` would still + * pass. That is the exact drift this card is about, so the pin has to be + * invariant to say anything at all. + */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The binding itself ──────────────────────────────────────────────────── */ + +/** + * The whole card in one line: what `@object-ui/mobile` publishes under + * `SpecResponsiveConfig` and what the schema declares are ONE type. Red on the + * day any link in mobile → `@object-ui/types` → `@objectstack/spec/ui` is + * replaced by a copy, however byte-identical it starts out. + */ +type _IsExactlyTheSchemaType = Expect< Equal< SpecResponsiveConfig, ResponsiveConfig > >; + +/** + * The four keys `ResponsiveConfigSchema` declares. Adding one here to make a + * local read compile is the defect rather than the fix: the key has to exist in + * the schema first, or the platform refuses metadata that declares it. + */ +type SchemaDeclaredKeys = 'breakpoint' | 'hiddenOn' | 'columns' | 'order'; + +type _KeysAreExactlyTheSchemaFour = Expect< Equal< keyof SpecResponsiveConfig, SchemaDeclaredKeys > >; + +/** Every key is optional on the authoring side — none of the four is required. */ +type _AllFourAreOptional = Expect< + Equal< SpecResponsiveConfig, Partial< SpecResponsiveConfig > > +>; + +/** + * The breakpoint vocabulary is the schema's six, reached through the same + * chain. `NonNullable` keeps this pointed at which names exist rather than at + * the `| undefined` the optionality puts there on purpose. + */ +type _BreakpointVocabularyIsTheSchemaSix = Expect< + Equal< + NonNullable< SpecResponsiveConfig['breakpoint'] >, + 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' + > +>; + +/* eslint-enable @typescript-eslint/no-unused-vars */ + +/* ── Runtime half ────────────────────────────────────────────────────────── */ + +// The type-level assertions above are erased before anything runs, so the +// checks below re-ask the same questions of the schema VALUE. Without them this +// file could keep compiling while the runtime contract moved underneath it. +describe('SpecResponsiveConfig is the spec responsive config (objectui#4598)', () => { + const schemaKeys = Object.keys(ResponsiveConfigSchema.shape).sort(); + + it('reads the four declared keys off the schema itself', () => { + expect(schemaKeys, 'could not read ResponsiveConfigSchema.shape').toEqual( + ['breakpoint', 'columns', 'hiddenOn', 'order'], + ); + }); + + it('accepts a fully-populated config, and the schema agrees', () => { + const full: SpecResponsiveConfig = { + breakpoint: 'md', + hiddenOn: ['xs', 'sm'], + columns: { xs: 12, sm: 6, lg: 4 }, + order: { xs: 2, lg: 1 }, + }; + + expect(Object.keys(full).sort()).toEqual(schemaKeys); + expect(ResponsiveConfigSchema.parse(full)).toEqual(full); + }); + + it('accepts the empty config — every key is optional', () => { + const empty: SpecResponsiveConfig = {}; + expect(ResponsiveConfigSchema.parse(empty)).toEqual({}); + }); + + it('carries `2xl`, the breakpoint a five-name copy would be missing', () => { + const widest: SpecResponsiveConfig = { breakpoint: '2xl', columns: { '2xl': 3 } }; + expect(ResponsiveConfigSchema.parse(widest)).toEqual(widest); + }); + + it('refuses a breakpoint outside the schema enum', () => { + const bad: SpecResponsiveConfig = { + // @ts-expect-error 'xxl' is not one of the six declared breakpoint names + breakpoint: 'xxl', + }; + expect(() => ResponsiveConfigSchema.parse(bad)).toThrow(); + }); + + it('refuses a key the schema does not declare', () => { + const bad: SpecResponsiveConfig = { + breakpoint: 'lg', + // @ts-expect-error `visibleOn` is not a declared responsive key + visibleOn: ['xs'], + }; + // The schema is strict, so the undeclared key is a runtime rejection too — + // the type and the schema refuse the same authored metadata. + expect(() => ResponsiveConfigSchema.parse(bad)).toThrow(); + }); +}); diff --git a/packages/mobile/src/useResponsiveConfig.ts b/packages/mobile/src/useResponsiveConfig.ts index a1dd718932..eca51c3b02 100644 --- a/packages/mobile/src/useResponsiveConfig.ts +++ b/packages/mobile/src/useResponsiveConfig.ts @@ -9,10 +9,30 @@ import { useMemo } from 'react'; import { useBreakpoint } from './useBreakpoint'; import { resolveResponsiveValue } from './breakpoints'; -import type { BreakpointName } from '@object-ui/types'; +import type { BreakpointName, SpecResponsiveConfig } from '@object-ui/types'; /** - * Spec-aligned ResponsiveConfig (mirrors @objectstack/spec ResponsiveConfigSchema). + * The responsive layout config this hook consumes — re-exported, not re-declared + * (objectui#4598). + * + * This used to be a hand-written interface over the same four keys, under a + * comment asserting it mirrored the schema. The assertion was true on the day it + * was written and maintained by nobody after that: the interface named the + * schema without ever referring to it, so a key added or retired upstream would + * have moved the two apart in silence. `ViewNavigationConfig` (objectui#4588) + * read the same way until it had drifted on `mode`. + * + * `@object-ui/types` — already this package's only runtime dependency — publishes + * the schema's own type at `index.ts` under this exact name, imported from + * `@objectstack/spec/ui` rather than copied. Re-exporting it costs no new + * dependency edge and leaves the published name unchanged, so the four keys are + * now whatever the schema says they are rather than whatever this file last + * remembered. `@object-ui/core`'s `ResponsiveProtocol` already binds through the + * same re-export. + * + * `responsive-config-spec-parity.test.ts` pins the chain to the schema itself, + * because the one link this file cannot see is `@object-ui/types` re-growing a + * hand copy of its own. * * @example * ```ts @@ -23,16 +43,7 @@ import type { BreakpointName } from '@object-ui/types'; * }; * ``` */ -export interface SpecResponsiveConfig { - /** The target breakpoint for this config */ - breakpoint?: BreakpointName; - /** Breakpoints on which the component is hidden */ - hiddenOn?: BreakpointName[]; - /** Grid column counts per breakpoint (1-12) */ - columns?: Partial>; - /** Display order per breakpoint */ - order?: Partial>; -} +export type { SpecResponsiveConfig }; /** * Resolved responsive state from a SpecResponsiveConfig. diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 0dee97f7ad..60754092c2 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -497,7 +497,6 @@ const CLAIM_DEBT = { "@object-ui/i18n": ["SpecDateFormat", "SpecLocaleConfig", "SpecNumberFormat", "SpecPluralRule"], "@object-ui/core": ["ResultDialogFieldSpec", "ViewDataConfig"], "@object-ui/app-shell": ["RecordLookupBinding"], - "@object-ui/mobile": ["SpecResponsiveConfig"], "@object-ui/plugin-view": ["ROW_HEIGHT_OPTIONS"], "@object-ui/react": ["DensityModeValue"], };