Skip to content

Commit c911544

Browse files
yinlianghuiclaude
andauthored
fix(mobile): SpecResponsiveConfig derives from its claim's source (#4598) (#4604)
`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. Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae10a01 commit c911544

4 files changed

Lines changed: 206 additions & 13 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/mobile': patch
3+
---
4+
5+
`SpecResponsiveConfig` is now the spec's responsive config rather than a hand copy that said it was
6+
7+
`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.
8+
9+
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`.
10+
11+
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.
12+
13+
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.
14+
15+
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.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `@object-ui/mobile`'s `SpecResponsiveConfig` IS the schema's type (objectui#4598).
11+
*
12+
* The card: `useResponsiveConfig.ts` hand-declared an interface over the four
13+
* responsive keys, renamed off the schema's own symbol (`ResponsiveConfig` →
14+
* `SpecResponsiveConfig`), under a comment saying it mirrored
15+
* `ResponsiveConfigSchema`. Nothing tied the two together — no import, no
16+
* `z.infer`, no compile-time reference of any kind. It AGREED key-for-key when
17+
* it was filed, which is the whole point: the agreement was maintained by
18+
* nobody and checked by nothing, and the comment already told the next session
19+
* the copy was canonical. `ViewNavigationConfig` (objectui#4588) read exactly
20+
* like that until it had drifted on `mode`.
21+
*
22+
* The fix re-exports the type from `@object-ui/types` — this package's only
23+
* runtime dependency, which publishes it imported straight from
24+
* `@objectstack/spec/ui` — so no new dependency edge was needed and the
25+
* published name did not move.
26+
*
27+
* ── Why this file exists, given the fix is a re-export ──────────────────────
28+
*
29+
* `scripts/check-spec-symbol-derivation.mjs` stops reporting the declaration
30+
* because after the fix there is no declaration: rule 2 collects type aliases,
31+
* interfaces, enums and variables, and a bare `export type { … }` is none of
32+
* those. That is the right outcome — the hand copy is gone — but the gate got
33+
* there by seeing nothing, not by following the chain. It cannot: the binding
34+
* now runs mobile → `@object-ui/types` → `@objectstack/spec/ui`, and the gate
35+
* reads one package at a time.
36+
*
37+
* So the middle link is the part no gate covers, and it is the only place this
38+
* fix can still rot: if `@object-ui/types` ever replaces its re-export with a
39+
* hand copy of its own, mobile inherits the copy silently and every check in
40+
* the repo stays green. The assertions below are written against
41+
* `@objectstack/spec/ui` DIRECTLY for that reason — pinning against
42+
* `@object-ui/types` would compare the import to itself and pass no matter what
43+
* either package did.
44+
*
45+
* The type-level half runs under `tsc -p tsconfig.test.json` (this package's
46+
* `type-check`), so a re-grown copy fails the build rather than waiting for a
47+
* reviewer. `@objectstack/spec` is a devDependency here — a test may import it;
48+
* the published `.d.ts` may not, which is why the source re-exports through
49+
* `@object-ui/types` instead of reaching for the spec directly.
50+
*/
51+
52+
import { describe, it, expect } from 'vitest';
53+
import { ResponsiveConfigSchema, type ResponsiveConfig } from '@objectstack/spec/ui';
54+
import type { SpecResponsiveConfig } from '../index';
55+
56+
/* eslint-disable @typescript-eslint/no-unused-vars --
57+
* The pins below are compile-time assertions: `tsc` is their only consumer and
58+
* they are erased before anything runs, so "unused" is what a passing one looks
59+
* like. The repo's `no-unused-vars` ignores `^_` for ARGUMENTS only, which is
60+
* why `packages/types`' equivalent file carries the same warnings; scoping the
61+
* exemption to this file keeps it from becoming a repo-wide hole.
62+
*/
63+
64+
/* ── Type-level helpers ──────────────────────────────────────────────────── */
65+
66+
/**
67+
* Invariant equality. `extends` in one direction — or a `satisfies` check —
68+
* would accept a NARROWING, so a copy that quietly dropped `order` would still
69+
* pass. That is the exact drift this card is about, so the pin has to be
70+
* invariant to say anything at all.
71+
*/
72+
type Equal< A, B > =
73+
(< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
74+
type Expect< T extends true > = T;
75+
76+
/* ── The binding itself ──────────────────────────────────────────────────── */
77+
78+
/**
79+
* The whole card in one line: what `@object-ui/mobile` publishes under
80+
* `SpecResponsiveConfig` and what the schema declares are ONE type. Red on the
81+
* day any link in mobile → `@object-ui/types` → `@objectstack/spec/ui` is
82+
* replaced by a copy, however byte-identical it starts out.
83+
*/
84+
type _IsExactlyTheSchemaType = Expect< Equal< SpecResponsiveConfig, ResponsiveConfig > >;
85+
86+
/**
87+
* The four keys `ResponsiveConfigSchema` declares. Adding one here to make a
88+
* local read compile is the defect rather than the fix: the key has to exist in
89+
* the schema first, or the platform refuses metadata that declares it.
90+
*/
91+
type SchemaDeclaredKeys = 'breakpoint' | 'hiddenOn' | 'columns' | 'order';
92+
93+
type _KeysAreExactlyTheSchemaFour = Expect< Equal< keyof SpecResponsiveConfig, SchemaDeclaredKeys > >;
94+
95+
/** Every key is optional on the authoring side — none of the four is required. */
96+
type _AllFourAreOptional = Expect<
97+
Equal< SpecResponsiveConfig, Partial< SpecResponsiveConfig > >
98+
>;
99+
100+
/**
101+
* The breakpoint vocabulary is the schema's six, reached through the same
102+
* chain. `NonNullable` keeps this pointed at which names exist rather than at
103+
* the `| undefined` the optionality puts there on purpose.
104+
*/
105+
type _BreakpointVocabularyIsTheSchemaSix = Expect<
106+
Equal<
107+
NonNullable< SpecResponsiveConfig['breakpoint'] >,
108+
'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
109+
>
110+
>;
111+
112+
/* eslint-enable @typescript-eslint/no-unused-vars */
113+
114+
/* ── Runtime half ────────────────────────────────────────────────────────── */
115+
116+
// The type-level assertions above are erased before anything runs, so the
117+
// checks below re-ask the same questions of the schema VALUE. Without them this
118+
// file could keep compiling while the runtime contract moved underneath it.
119+
describe('SpecResponsiveConfig is the spec responsive config (objectui#4598)', () => {
120+
const schemaKeys = Object.keys(ResponsiveConfigSchema.shape).sort();
121+
122+
it('reads the four declared keys off the schema itself', () => {
123+
expect(schemaKeys, 'could not read ResponsiveConfigSchema.shape').toEqual(
124+
['breakpoint', 'columns', 'hiddenOn', 'order'],
125+
);
126+
});
127+
128+
it('accepts a fully-populated config, and the schema agrees', () => {
129+
const full: SpecResponsiveConfig = {
130+
breakpoint: 'md',
131+
hiddenOn: ['xs', 'sm'],
132+
columns: { xs: 12, sm: 6, lg: 4 },
133+
order: { xs: 2, lg: 1 },
134+
};
135+
136+
expect(Object.keys(full).sort()).toEqual(schemaKeys);
137+
expect(ResponsiveConfigSchema.parse(full)).toEqual(full);
138+
});
139+
140+
it('accepts the empty config — every key is optional', () => {
141+
const empty: SpecResponsiveConfig = {};
142+
expect(ResponsiveConfigSchema.parse(empty)).toEqual({});
143+
});
144+
145+
it('carries `2xl`, the breakpoint a five-name copy would be missing', () => {
146+
const widest: SpecResponsiveConfig = { breakpoint: '2xl', columns: { '2xl': 3 } };
147+
expect(ResponsiveConfigSchema.parse(widest)).toEqual(widest);
148+
});
149+
150+
it('refuses a breakpoint outside the schema enum', () => {
151+
const bad: SpecResponsiveConfig = {
152+
// @ts-expect-error 'xxl' is not one of the six declared breakpoint names
153+
breakpoint: 'xxl',
154+
};
155+
expect(() => ResponsiveConfigSchema.parse(bad)).toThrow();
156+
});
157+
158+
it('refuses a key the schema does not declare', () => {
159+
const bad: SpecResponsiveConfig = {
160+
breakpoint: 'lg',
161+
// @ts-expect-error `visibleOn` is not a declared responsive key
162+
visibleOn: ['xs'],
163+
};
164+
// The schema is strict, so the undeclared key is a runtime rejection too —
165+
// the type and the schema refuse the same authored metadata.
166+
expect(() => ResponsiveConfigSchema.parse(bad)).toThrow();
167+
});
168+
});

‎packages/mobile/src/useResponsiveConfig.ts‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,30 @@
99
import { useMemo } from 'react';
1010
import { useBreakpoint } from './useBreakpoint';
1111
import { resolveResponsiveValue } from './breakpoints';
12-
import type { BreakpointName } from '@object-ui/types';
12+
import type { BreakpointName, SpecResponsiveConfig } from '@object-ui/types';
1313

1414
/**
15-
* Spec-aligned ResponsiveConfig (mirrors @objectstack/spec ResponsiveConfigSchema).
15+
* The responsive layout config this hook consumes — re-exported, not re-declared
16+
* (objectui#4598).
17+
*
18+
* This used to be a hand-written interface over the same four keys, under a
19+
* comment asserting it mirrored the schema. The assertion was true on the day it
20+
* was written and maintained by nobody after that: the interface named the
21+
* schema without ever referring to it, so a key added or retired upstream would
22+
* have moved the two apart in silence. `ViewNavigationConfig` (objectui#4588)
23+
* read the same way until it had drifted on `mode`.
24+
*
25+
* `@object-ui/types` — already this package's only runtime dependency — publishes
26+
* the schema's own type at `index.ts` under this exact name, imported from
27+
* `@objectstack/spec/ui` rather than copied. Re-exporting it costs no new
28+
* dependency edge and leaves the published name unchanged, so the four keys are
29+
* now whatever the schema says they are rather than whatever this file last
30+
* remembered. `@object-ui/core`'s `ResponsiveProtocol` already binds through the
31+
* same re-export.
32+
*
33+
* `responsive-config-spec-parity.test.ts` pins the chain to the schema itself,
34+
* because the one link this file cannot see is `@object-ui/types` re-growing a
35+
* hand copy of its own.
1636
*
1737
* @example
1838
* ```ts
@@ -23,16 +43,7 @@ import type { BreakpointName } from '@object-ui/types';
2343
* };
2444
* ```
2545
*/
26-
export interface SpecResponsiveConfig {
27-
/** The target breakpoint for this config */
28-
breakpoint?: BreakpointName;
29-
/** Breakpoints on which the component is hidden */
30-
hiddenOn?: BreakpointName[];
31-
/** Grid column counts per breakpoint (1-12) */
32-
columns?: Partial<Record<BreakpointName, number>>;
33-
/** Display order per breakpoint */
34-
order?: Partial<Record<BreakpointName, number>>;
35-
}
46+
export type { SpecResponsiveConfig };
3647

3748
/**
3849
* Resolved responsive state from a SpecResponsiveConfig.

‎scripts/check-spec-symbol-derivation.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,6 @@ const CLAIM_DEBT = {
497497
"@object-ui/i18n": ["SpecDateFormat", "SpecLocaleConfig", "SpecNumberFormat", "SpecPluralRule"],
498498
"@object-ui/core": ["ResultDialogFieldSpec", "ViewDataConfig"],
499499
"@object-ui/app-shell": ["RecordLookupBinding"],
500-
"@object-ui/mobile": ["SpecResponsiveConfig"],
501500
"@object-ui/plugin-view": ["ROW_HEIGHT_OPTIONS"],
502501
"@object-ui/react": ["DensityModeValue"],
503502
};

0 commit comments

Comments
 (0)