From a44c585345a7fd20da68f53a0511e8ae80d9263a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 00:33:02 +0000 Subject: [PATCH 1/3] fix(plugin-detail,plugin-form): let the declaration type the useRecordContext bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `record:activity` renderer and the line-items panel each bound the whole record context through a type assertion, so every member read below them was `any` regardless of how those reads were written — which is why removing a narrower assertion at one read site changed nothing the compiler answers. Drop both assertions and let `RecordContextValue` do the typing, and pin the shape with a census keyed on the BINDING rather than on an identifier name, carrying compile-time controls that prove the declaration really resolves instead of degrading to `any`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../src/renderers/record-activity.tsx | 2 +- packages/plugin-form/src/LineItemsPanel.tsx | 2 +- .../useRecordContext.bindingCast.pin.test.ts | 322 ++++++++++++++++++ 3 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts diff --git a/packages/plugin-detail/src/renderers/record-activity.tsx b/packages/plugin-detail/src/renderers/record-activity.tsx index f501a57d74..9d7d23b07a 100644 --- a/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/packages/plugin-detail/src/renderers/record-activity.tsx @@ -96,7 +96,7 @@ export const RecordActivityRenderer: React.FC = ({ ...props }) => { const { designer } = splitDesigner(props); - const ctx = useRecordContext() as any; + const ctx = useRecordContext(); const discussion = useDiscussionContext(); const tt = useSafeTranslate(); diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 3329f9c94f..bf2bfef6bd 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -91,7 +91,7 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // Studio designer/palette), so it never throws — call it unconditionally to // keep hook order stable across renders. A null record just means "no parent // record bound", which the optional chaining below already handles. - const record = useRecordContext() as any; + const record = useRecordContext(); const parentObject = schema.parentObject || record?.objectName; const parentId = diff --git a/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts b/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts new file mode 100644 index 0000000000..7f505e4fd8 --- /dev/null +++ b/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts @@ -0,0 +1,322 @@ +/** + * 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. + */ + +/** + * objectui#9304 — no consumer may bind `useRecordContext()` through a + * whole-context type assertion. + * + * ## What this pins, and why the read sites were not enough + * + * objectui#9197 retyped `RecordContextValue.dataSource` from an id to the + * `DataSource` adapter and removed the assertion at every site that READS the + * member. That claim is true and it landed. But in `record-activity.tsx` the + * `const ctx` binding itself was already asserted to `any` one scope above, so + * removing the narrower assertion on the read changed nothing the compiler + * answers: `ctx.dataSource` was `any` before and `any` after. A file can be + * cast-free at every read site and still be untyped. + * + * ⇒ the discriminant is the BINDING, not the identifier and not the read. + * The card carries the measurement that forces this: a cast list keyed on the + * NAME `ctx` produced a count that matched the real one by coincidence while + * overlapping on only six of eleven entries. An equal total is not an equal + * set, so this pin never counts — it enumerates call sites and classifies each + * one. + * + * ## Why a text scan, and what carries the type half + * + * A cast removal leaves no runtime trace: the value was always complete at + * runtime, which is objectui#9197's own argument. Nothing observable through + * rendering distinguishes the repaired tree from the broken one, so a + * behavioural test here would be green either way — the disease this card + * describes. The two halves below are therefore split on purpose: + * + * - the RUNTIME half classifies every `useRecordContext(...)` call site in the + * monorepo and reds on any that is immediately asserted. It is the half that + * reddens when an assertion comes back. + * - the COMPILE-TIME half proves the declaration really resolves here rather + * than degrading to `any` — the characteristic dead instrument on a + * type-only card. `Equal` distinguishes `any` from every other type, and the + * `@ts-expect-error` control fails as an UNUSED directive (TS2578) if the + * checker stops refusing a misspelled member. Both run under + * `packages/react`'s `tsconfig.test.json`, never at runtime. + * + * Every control below can fire: the matcher is proven on a synthetic asserted + * binding, the comment mask is proven on a commented one, and the population is + * proven non-empty and to contain the known consumers before any absence is + * believed. + */ + +import { describe, it, expect } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { DataSource } from '@object-ui/types'; +import type { useRecordContext, RecordContextValue } from '../RecordContext'; + +/* ------------------------------------------------------------------ * + * Compile-time half — erased at runtime; `tsc -p tsconfig.test.json` + * is the only thing that executes it. + * ------------------------------------------------------------------ */ + +type Equal = + (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +type Expect = T; + +/** + * The hook's answer is the declaration, not `any`. `Equal` is the form that + * can tell those apart — a plain `extends` check passes for `any` in both + * directions and would pin nothing. + */ +type _HookReturnsTheDeclaration = Expect< + Equal, RecordContextValue | null> +>; + +/** + * The member the removed assertions used to hide. `DataSource | undefined`, + * not `string` and not `any` — objectui#9197's retype, read through the + * declaration a consumer now gets for free. + */ +type _DataSourceIsTheAdapter = Expect< + Equal +>; + +/** + * Control: the checker is live in this file. If module resolution ever handed + * `RecordContextValue` back as `any`, this directive would become UNUSED and + * `tsc` would fail with TS2578 — which is the point of writing the control as + * an expected error rather than as another assertion. + */ +// @ts-expect-error `objectNam` is not a declared member of RecordContextValue +type _MisspelledMemberIsRefused = RecordContextValue['objectNam']; + +/* ------------------------------------------------------------------ * + * Runtime half — the binding-keyed census. + * ------------------------------------------------------------------ */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/react/src/context/__tests__ -> repo root +const repoRoot = path.resolve(here, '../../../../..'); + +/** Top-level directories that can hold a TypeScript consumer of the hook. */ +const SCAN_ROOTS = ['packages', 'apps', 'examples', 'e2e', 'scripts']; + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.next', + '.turbo', + '.vite', + '.git', +]); + +/** + * Mask `//` and block comments with spaces, leaving every other byte — string + * contents included — in place. Quoted spans are walked rather than blanked, + * purely so a `//` inside a URL literal is not read as a comment start. + */ +function maskComments(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + const d = src[i + 1]; + if (c === '/' && d === '/') { + while (i < n && src[i] !== '\n') out[i++] = ' '; + continue; + } + if (c === '/' && d === '*') { + out[i++] = ' '; + out[i++] = ' '; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { + if (src[i] !== '\n') out[i] = ' '; + i++; + } + if (i < n) { + out[i++] = ' '; + out[i++] = ' '; + } + continue; + } + if (c === '"' || c === "'" || c === '`') { + const quote = c; + i++; + while (i < n) { + if (src[i] === '\\') { + i += 2; + continue; + } + if (src[i] === quote) { + i++; + break; + } + i++; + } + continue; + } + i++; + } + return out.join(''); +} + +/** Every `useRecordContext(...)` call, asserted or not. */ +const CALL = /useRecordContext\s*(?:<[^<>()]*>)?\s*\([^()]*\)/g; +/** The same call, immediately followed by a type assertion. */ +const CALL_THEN_AS = /useRecordContext\s*(?:<[^<>()]*>)?\s*\([^()]*\)\s*as\b/g; + +interface Site { + /** Repo-relative path of the citing file. */ + file: string; + /** 1-based line of the call inside that file. */ + line: number; + /** The matched text, trimmed. */ + text: string; +} + +function lineOf(src: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) if (src[i] === '\n') line++; + return line; +} + +function matchSites(file: string, masked: string, re: RegExp): Site[] { + const found: Site[] = []; + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(masked)) !== null) { + found.push({ file, line: lineOf(masked, m.index), text: m[0].replace(/\s+/g, ' ') }); + } + return found; +} + +function collectSourceFiles(): string[] { + const files: string[] = []; + const walk = (dir: string) => { + let entries: ReturnType; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + walk(path.join(dir, e.name)); + } else if (e.isFile() && /\.tsx?$/.test(e.name)) { + files.push(path.join(dir, e.name)); + } + } + }; + for (const root of SCAN_ROOTS) walk(path.join(repoRoot, root)); + return files; +} + +/** Files, call sites and asserted call sites — computed once. */ +const SOURCE_FILES = collectSourceFiles(); + +const ALL_SITES: Site[] = []; +const ASSERTED_SITES: Site[] = []; +for (const abs of SOURCE_FILES) { + const raw = readFileSync(abs, 'utf8'); + if (!raw.includes('useRecordContext')) continue; + const rel = path.relative(repoRoot, abs).split(path.sep).join('/'); + const masked = maskComments(raw); + ALL_SITES.push(...matchSites(rel, masked, CALL)); + ASSERTED_SITES.push(...matchSites(rel, masked, CALL_THEN_AS)); +} + +/** + * Consumers that must be inside the population for any absence below to be a + * reading rather than a collapsed scan. All four bind the hook today; the two + * this card repaired are named first. + */ +const KNOWN_CONSUMERS = [ + 'packages/plugin-detail/src/renderers/record-activity.tsx', + 'packages/plugin-form/src/LineItemsPanel.tsx', + 'packages/plugin-detail/src/renderers/record-history.tsx', + 'packages/components/src/renderers/layout/containers.tsx', +]; + +describe('the instrument can fire (controls)', () => { + it('anchors on this file, not on the cwd', () => { + expect(existsSync(path.join(repoRoot, 'pnpm-workspace.yaml'))).toBe(true); + expect(existsSync(path.join(repoRoot, 'packages/react/src/context/RecordContext.tsx'))).toBe( + true, + ); + }); + + it('flags an asserted binding', () => { + // Assembled from fragments so this control is not itself a hit when the + // census below walks this file — which it does, like any other source. + const asserted = 'const ctx = useRecordContext()' + ' as ' + 'any;'; + expect(matchSites('synthetic.ts', maskComments(asserted), CALL_THEN_AS)).toHaveLength(1); + }); + + it('flags an asserted binding written with explicit type arguments', () => { + const asserted = + 'const ctx = useRecordContext()' + ' as ' + 'Record;'; + expect(matchSites('synthetic.ts', maskComments(asserted), CALL_THEN_AS)).toHaveLength(1); + }); + + it('does not flag a plain binding', () => { + const plain = 'const ctx = useRecordContext();'; + expect(matchSites('synthetic.ts', maskComments(plain), CALL)).toHaveLength(1); + expect(matchSites('synthetic.ts', maskComments(plain), CALL_THEN_AS)).toHaveLength(0); + }); + + it('masks comments, so prose about the defect is not a defect', () => { + const commented = '// const ctx = useRecordContext()' + ' as ' + 'any;\nconst x = 1;'; + expect(matchSites('synthetic.ts', maskComments(commented), CALL_THEN_AS)).toHaveLength(0); + const blockCommented = '/* useRecordContext()' + ' as ' + 'any */\nconst x = 1;'; + expect(matchSites('synthetic.ts', maskComments(blockCommented), CALL_THEN_AS)).toHaveLength(0); + }); + + it('does not mistake a URL inside a string for a comment', () => { + const withUrl = + "const url = 'https://example.test/x';\nconst ctx = useRecordContext()" + ' as ' + 'any;'; + expect(matchSites('synthetic.ts', maskComments(withUrl), CALL_THEN_AS)).toHaveLength(1); + }); + + it('walked a real population, not an empty one', () => { + expect(SOURCE_FILES.length).toBeGreaterThan(500); + // A floor, never an exact count: an equal total is not an equal set, so the + // assertion that matters is the membership read below. + expect(ALL_SITES.length).toBeGreaterThanOrEqual(10); + }); + + it('reached every known consumer of the hook', () => { + const filesWithSites = new Set(ALL_SITES.map((s) => s.file)); + for (const consumer of KNOWN_CONSUMERS) { + expect( + existsSync(path.join(repoRoot, consumer)), + `${consumer} is gone — update KNOWN_CONSUMERS rather than deleting the control`, + ).toBe(true); + expect(filesWithSites.has(consumer), `${consumer} carries no scanned call site`).toBe(true); + } + }); +}); + +describe('useRecordContext bindings are typed by the declaration (objectui#9304)', () => { + it('no call site is bound through a type assertion', () => { + const offenders = ASSERTED_SITES.map((s) => `${s.file} line ${s.line} ${s.text}`); + expect( + offenders, + [ + 'A `useRecordContext()` call is bound through a type assertion.', + 'The declaration (`RecordContextValue | null`) already types every member a', + 'record renderer reads, so an assertion here only hides what the compiler', + 'would have answered — and it keeps hiding it at every read below,', + 'whether or not those reads carry casts of their own (objectui#9304).', + 'Read the members off the binding instead; if one is genuinely missing,', + 'declare it on `RecordContextValue`.', + ].join('\n'), + ).toEqual([]); + }); +}); From 2c7d88ed0e18789e91816a9ad10738e8c943034f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 00:38:22 +0000 Subject: [PATCH 2/3] docs(plugin-form): record what the inner parent-id assertion now carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured while removing the whole-context assertion above it: dropping the `record?.recordId` assertion is TS2345 (`string | number` is not assignable to `string`), not a redundancy — `RecordContextValue.recordId` is declared wider than the parent id `buildMasterDetailEditBatch` accepts. It did nothing at all while the binding was `any`; it does real work now, and the next reader needs to know which. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- packages/plugin-form/src/LineItemsPanel.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index bf2bfef6bd..927ae6f286 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -94,6 +94,19 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch const record = useRecordContext(); const parentObject = schema.parentObject || record?.objectName; + // The assertion below is LOAD-BEARING, and only became so when the + // whole-context assertion on `record` was removed (objectui#9304). While the + // binding was `any` it did nothing at all; now `RecordContextValue.recordId` + // is declared `string | number | null | undefined` and + // `buildMasterDetailEditBatch` takes a `string` parent id, so dropping it is + // a real error rather than a tidy-up — measured: TS2345, `string | number` + // is not assignable to `string`. + // + // Kept rather than repaired here because both repairs move bytes on the wire + // for a numeric primary key (coercing with `String()` changes the id this + // panel sends; widening `masterDetailTx`'s parameter is that module's + // contract, not this one's), and this change is type-side with no runtime + // effect. The residue is tracked separately. const parentId = schema.parentId || schema.recordId || (record?.recordId as string | undefined); From 61d8ea70d03df6d2bfba59dc1f71ad1acede5535 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 00:47:39 +0000 Subject: [PATCH 3/3] test(react): repair two compile-time faults the pin's own controls caught, and declare the level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReturnType` of an overloaded `readdirSync` resolves to the LAST overload (the Buffer one), and `ReturnType` of a generic hook does not instantiate its type parameters the way a bare call site does — both surfaced as `tsc` errors in the pin itself rather than as a quietly passing assertion, which is what the compile-time half is there to do. The "not any" clause is now written first and separately, since a plain `extends` check is satisfied by `any`. The changeset declares no release: every emitted `.js`, `.css` and `.d.ts` of both touched packages is byte-identical to the base tree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/record-context-binding-casts.md | 18 ++++++++++++++ .../useRecordContext.bindingCast.pin.test.ts | 24 ++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 .changeset/record-context-binding-casts.md diff --git a/.changeset/record-context-binding-casts.md b/.changeset/record-context-binding-casts.md new file mode 100644 index 0000000000..8c11a6d133 --- /dev/null +++ b/.changeset/record-context-binding-casts.md @@ -0,0 +1,18 @@ +--- +--- + +No release. Removes the two whole-context type assertions on +`useRecordContext()` — in the `record:activity` renderer and in the line-items +panel — so `RecordContextValue` types those bindings, and adds a census that +keys on the BINDING rather than on an identifier name so the shape cannot come +back (objectui#9304). + +Declared as no release because the level follows a measurement rather than a +judgement. A TypeScript type assertion erases, and both renderers export an +explicitly annotated `React.FC`, so nothing inferred reaches the emitted +declarations. Building `@object-ui/plugin-detail` and `@object-ui/plugin-form` +from the base tree and from this one produces 172 dist files of which 171 are +byte-identical by sha256; every emitted `.js`, `.css` and `.d.ts` is among +them. The single difference is one declaration sourcemap, whose `mappings` +shift because an explanatory comment was added above an unchanged statement. +Nothing a consumer of these packages can resolve, import or execute changes. diff --git a/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts b/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts index 7f505e4fd8..16b64ac884 100644 --- a/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts +++ b/packages/react/src/context/__tests__/useRecordContext.bindingCast.pin.test.ts @@ -67,13 +67,18 @@ type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; type Expect = T; +type HookReturn = ReturnType; + /** * The hook's answer is the declaration, not `any`. `Equal` is the form that * can tell those apart — a plain `extends` check passes for `any` in both - * directions and would pin nothing. + * directions and would pin nothing, which is why the "not any" clause is + * written first and separately. */ -type _HookReturnsTheDeclaration = Expect< - Equal, RecordContextValue | null> +type _HookReturnIsNotAny = Expect, false>>; +type _HookReturnAdmitsNull = Expect, null>>; +type _HookReturnIsTheContextValue = Expect< + NonNullable extends RecordContextValue ? true : false >; /** @@ -198,14 +203,17 @@ function matchSites(file: string, masked: string, re: RegExp): Site[] { function collectSourceFiles(): string[] { const files: string[] = []; - const walk = (dir: string) => { - let entries: ReturnType; + // Not annotated, deliberately: `ReturnType` picks the + // LAST overload of an overloaded declaration, which is the Buffer one. + const readDir = (dir: string) => { try { - entries = readdirSync(dir, { withFileTypes: true }); + return readdirSync(dir, { withFileTypes: true }); } catch { - return; + return []; } - for (const e of entries) { + }; + const walk = (dir: string) => { + for (const e of readDir(dir)) { if (e.isDirectory()) { if (SKIP_DIRS.has(e.name)) continue; walk(path.join(dir, e.name));