Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/mobile-responsive-config-derives-4598.md
Original file line number Diff line number Diff line change
@@ -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.
168 changes: 168 additions & 0 deletions packages/mobile/src/__tests__/responsive-config-spec-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
35 changes: 23 additions & 12 deletions packages/mobile/src/useResponsiveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Record<BreakpointName, number>>;
/** Display order per breakpoint */
order?: Partial<Record<BreakpointName, number>>;
}
export type { SpecResponsiveConfig };

/**
* Resolved responsive state from a SpecResponsiveConfig.
Expand Down
1 change: 0 additions & 1 deletion scripts/check-spec-symbol-derivation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
};
Expand Down
Loading