From e44132f63abbb4ce27250c87a3a1ac39df81d30d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:47:37 +0000 Subject: [PATCH 1/2] fix(plugin-form,plugin-list,plugin-view,react): read SchemaRendererContext as declared, not through a cast to any Five reads of the renderer context erased SchemaRendererContextType: four `useContext(SchemaRendererContext as React.Context)` casts (the embeddable-form and object-master-detail-form bridges, ListViewBlock, useResolvedDataSource) and plugin-view's module-level re-declaration of the imported context as `React.Context`. Through them a read of a member the context does not declare compiled clean - the mechanism behind the phantom `ctx.formValues ?? ctx.data` channel objectui#7206 retired. Every cast is gone. Dropping them surfaced no undeclared-member read; it surfaced two places where the declared `null` (no adapter bound) met a prop that does not admit it: - embeddable-form handed it to EmbeddableForm's optional `dataSource`; it now collapses to `undefined`, as object-master-detail-form already did. EmbeddableForm only tests the adapter for truthiness. - object-view hands it to ObjectViewProps.dataSource, declared REQUIRED and kept so (objectui#7842). That single value now carries a narrow, commented assertion instead of the whole context being erased. No runtime value changes. useResolvedDataSource's emitted declaration is unchanged. Each site is pinned by a ts.Program leg in its own package that reads the hook call's type, calibrated by planting the cast back in memory. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...chema-renderer-context-read-as-declared.md | 34 +++ .../schemaRendererContextRead-7209.test.ts | 249 ++++++++++++++++++ packages/plugin-form/src/index.tsx | 16 +- packages/plugin-list/src/ListViewBlock.tsx | 4 +- ...ock.schemaRendererContextRead-7209.test.ts | 227 ++++++++++++++++ ...rer.schemaRendererContextRead-7209.test.ts | 231 ++++++++++++++++ packages/plugin-view/src/index.tsx | 23 +- .../ElementDataSourceGate.tsx | 5 +- ...rce.schemaRendererContextRead-7209.test.ts | 229 ++++++++++++++++ 9 files changed, 1003 insertions(+), 15 deletions(-) create mode 100644 .changeset/7209-schema-renderer-context-read-as-declared.md create mode 100644 packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts create mode 100644 packages/plugin-list/src/__tests__/ListViewBlock.schemaRendererContextRead-7209.test.ts create mode 100644 packages/plugin-view/src/__tests__/objectViewRenderer.schemaRendererContextRead-7209.test.ts create mode 100644 packages/react/src/element-data-source/__tests__/useResolvedDataSource.schemaRendererContextRead-7209.test.ts diff --git a/.changeset/7209-schema-renderer-context-read-as-declared.md b/.changeset/7209-schema-renderer-context-read-as-declared.md new file mode 100644 index 0000000000..d1c7273bf8 --- /dev/null +++ b/.changeset/7209-schema-renderer-context-read-as-declared.md @@ -0,0 +1,34 @@ +--- +'@object-ui/plugin-form': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-view': patch +'@object-ui/react': patch +--- + +fix(plugin-form,plugin-list,plugin-view,react): read `SchemaRendererContext` as declared, not through a cast to `any` (objectui#7209) + +Five reads of the renderer context erased its type. Four cast at the read — +`useContext(SchemaRendererContext as React.Context)` in the +`embeddable-form` and `object-master-detail-form` bridges, in `ListViewBlock` +and in `useResolvedDataSource` — and the `object-view` renderer's module +re-declared the imported context as a `React.Context`. Through that cast a +read of a member the context does not declare compiled clean, which is how the +phantom `ctx.formValues ?? ctx.data` channel retired by objectui#7206 went +unnoticed. Each of these reads now sees `SchemaRendererContextType` as +`@object-ui/react` declares it, so such a read is a compile error on the day it +is written. + +No runtime behaviour changes. Removing the casts surfaced no read of an +undeclared member; it surfaced two places where the declared `null` ("no +adapter bound") met a prop that does not admit it: + +- `embeddable-form` handed that `null` to `EmbeddableForm`'s optional + `dataSource`. It now collapses it to `undefined`, as + `object-master-detail-form` already did; `EmbeddableForm` only ever tests the + adapter for truthiness, so the two absences behave the same. +- `object-view` / `view` hand it to `ObjectViewProps.dataSource`, which is + declared required and stays so (objectui#7842). That one value now carries a + narrow, commented assertion instead of the whole context being erased; the + value passed is the same as before. + +The published signature of `useResolvedDataSource` does not move. diff --git a/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts b/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts new file mode 100644 index 0000000000..877face52b --- /dev/null +++ b/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts @@ -0,0 +1,249 @@ +/** + * 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#7209 — `plugin-form`'s two registry bridges read + * `SchemaRendererContext` AS DECLARED. + * + * Both used to read it through + * `useContext(SchemaRendererContext as React.Context)`. That cast erased + * `SchemaRendererContextType` at the read, so a member the context does not + * declare type-checked clean — the mechanism behind objectui#7206's phantom + * `ctx.formValues ?? ctx.data` channel. With the declared type in place such a + * read is a compile error on the day it is written, which is the whole card. + * + * Sibling pins for the card's other sites, each over its own package's file: + * `ListViewBlock.schemaRendererContextRead-7209.test.ts` (plugin-list), + * `objectViewRenderer.schemaRendererContextRead-7209.test.ts` (plugin-view) and + * `useResolvedDataSource.schemaRendererContextRead-7209.test.ts` (react). + * + * ## Why a program, and not a `@ts-expect-error` in this file + * + * The value lives in a local inside each renderer. Nothing a test can import + * carries its type, so a directive written HERE would stay satisfied with the + * cast restored at the site. `getTypeAtLocation` on the hook call itself is the + * instrument that reports what each read carries — the shape + * `record-details.hideFieldsUncast-9965.test.ts` established in plugin-detail. + * + * ## What is derived, and what makes each verdict a reading + * + * - The DECLARED type is read off `@object-ui/react`'s own + * `SchemaRendererContext` export in the same program, never written here. + * - Each site is located by its renderer's NAME and must hold exactly one + * `useContext` of the context: zero or two throws, it does not pass. + * - CALIBRATION: the same calls with the cast planted back IN MEMORY read + * `any`, so "not `any`" is a verdict this instrument can actually reach. + * - PROBE: `formValues`, the member objectui#7206 retired, is not readable off + * the value. + * - LIT CONTROL: `dataSource`, a declared member, is — at its declared type. + * + * The program resolves `@object-ui/react` (and, through the root tsconfig's + * `paths`, `@object-ui/types` / `@object-ui/core`) to SOURCE: the test shards + * build nothing, so a `dist`-backed program would read the context as an + * unresolved `any` and report a false defect. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const slash = (p: string): string => p.replace(/\\/g, '/'); +const SITE_FILE = slash(join(HERE, '..', 'index.tsx')); +const CONTEXT_FILE = slash(join(REPO_ROOT, 'packages', 'react', 'src', 'context', 'SchemaRendererContext.tsx')); + +/** The renderers that read the context, by the name each is declared under. */ +const SITES = ['EmbeddableFormRenderer', 'MasterDetailFormRenderer'] as const; +/** The member objectui#7206 retired — it never existed on the context. */ +const PHANTOM = 'formValues'; +/** What the calibration leg plants back: the cast this card removed. */ +const PLANTED_CAST = " as import('react').Context"; + +interface Reading { + type: string; + isAny: boolean; + phantomReadable: boolean; + /** The `dataSource` member's type, or null when the value has no such member. */ + dataSource: string | null; +} + +/** + * A program over the site file, optionally with the site's text replaced in + * memory. Every other file is parsed once and shared between the two programs + * `measure()` builds, so the calibration program costs one file, not the tree. + */ +function programFactory(): (siteText?: string) => ts.Program { + const configPath = join(REPO_ROOT, 'tsconfig.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, ' ')); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, REPO_ROOT, undefined, configPath); + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + skipLibCheck: true, + types: [], + paths: { ...parsed.options.paths, '@object-ui/react': ['packages/react/src'] }, + }; + const host = ts.createCompilerHost(options); + const parse = host.getSourceFile.bind(host); + const parsedFiles = new Map(); + let override: string | undefined; + host.getSourceFile = (fileName, languageVersion, ...rest) => { + if (override !== undefined && slash(fileName) === SITE_FILE) { + return ts.createSourceFile(fileName, override, languageVersion, true); + } + if (!parsedFiles.has(fileName)) parsedFiles.set(fileName, parse(fileName, languageVersion, ...rest)); + return parsedFiles.get(fileName); + }; + return (siteText) => { + override = siteText; + return ts.createProgram([SITE_FILE], options, host); + }; +} + +/** The one `useContext(SchemaRendererContext…)` call inside the named declaration. */ +function hookCall(program: ts.Program, owner: string): ts.CallExpression { + const sf = program.getSourceFile(SITE_FILE); + if (!sf) throw new Error(`setup failure: ${SITE_FILE} is not a program input`); + let ownerNode: ts.Node | undefined; + const findOwner = (node: ts.Node): void => { + if (ownerNode) return; + if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) + && node.name && ts.isIdentifier(node.name) && node.name.text === owner) { + ownerNode = node; + return; + } + ts.forEachChild(node, findOwner); + }; + findOwner(sf); + if (!ownerNode) throw new Error(`setup failure: no declaration named \`${owner}\` in ${SITE_FILE}`); + const calls: ts.CallExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && /(^|\.)useContext$/.test(node.expression.getText(sf)) + && node.arguments.length === 1 + && /\bSchemaRendererContext\b/.test(node.arguments[0].getText(sf))) { + calls.push(node); + } + ts.forEachChild(node, walk); + }; + walk(ownerNode); + if (calls.length !== 1) { + throw new Error(`setup failure: \`${owner}\` holds ${calls.length} reads of SchemaRendererContext, expected exactly 1`); + } + return calls[0]; +} + +function describeValue(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): Reading { + const isAny = (type.flags & ts.TypeFlags.Any) !== 0; + const value = checker.getNonNullableType(type); + const member = checker.getPropertyOfType(value, 'dataSource'); + return { + type: checker.typeToString(type), + isAny, + phantomReadable: isAny + || checker.getPropertyOfType(value, PHANTOM) !== undefined + || checker.getIndexInfosOfType(value).length > 0, + dataSource: member ? checker.typeToString(checker.getTypeOfSymbolAtLocation(member, at)) : null, + }; +} + +/** `SchemaRendererContextType | null` as `@object-ui/react` exports it. */ +function declared(program: ts.Program): Reading { + const checker = program.getTypeChecker(); + const sf = program.getSourceFile(CONTEXT_FILE); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol) + .find((s) => s.name === 'SchemaRendererContext'); + if (!sf || !exported) throw new Error('setup failure: `SchemaRendererContext` did not resolve to its source export'); + const [valueType] = checker.getTypeArguments(checker.getTypeOfSymbol(exported) as ts.TypeReference); + if (!valueType) throw new Error('setup failure: the context export carries no type argument'); + return describeValue(checker, valueType, sf); +} + +function readSites(program: ts.Program): Record { + const checker = program.getTypeChecker(); + return Object.fromEntries(SITES.map((owner) => { + const call = hookCall(program, owner); + return [owner, describeValue(checker, checker.getTypeAtLocation(call), call)]; + })); +} + +interface Measurement { + declared: Reading; + sites: Record; + planted: Record; +} + +function measure(): Measurement { + const build = programFactory(); + const program = build(); + const sf = program.getSourceFile(SITE_FILE)!; + // Plant the cast after each context argument, last position first so the + // earlier offsets stay valid. + const ends = SITES.map((owner) => hookCall(program, owner).arguments[0].end).sort((a, b) => b - a); + let planted = sf.text; + for (const end of ends) planted = planted.slice(0, end) + PLANTED_CAST + planted.slice(end); + return { + declared: declared(program), + sites: readSites(program), + planted: readSites(build(planted)), + }; +} + +// MODULE SCOPE on purpose (AGENTS.md 测试纪律): the compiler work lands in the +// import phase, which no test or hook timeout bounds. A failure is kept and +// re-thrown by every test that reads the measurement, so it surfaces as named +// failures rather than as a collection error. +let outcome: Measurement | Error; +try { + outcome = measure(); +} catch (error) { + outcome = error instanceof Error ? error : new Error(String(error)); +} +const measurement = (): Measurement => { + if (outcome instanceof Error) throw outcome; + return outcome; +}; + +describe('objectui#7209 — the instrument is calibrated before it is believed', () => { + it('resolved the declared context type from `@object-ui/react`’s own export', () => { + const { declared: d } = measurement(); + expect(d.isAny).toBe(false); + expect(d.phantomReadable).toBe(false); + expect(d.dataSource).toMatch(/\bDataSource\b/); + }); + + for (const owner of SITES) { + it(`\`${owner}\` with the cast planted back IN MEMORY reads \`any\` — the verdicts below can fail`, () => { + expect(measurement().planted[owner]).toMatchObject({ isAny: true, phantomReadable: true, dataSource: null }); + }); + } +}); + +describe('objectui#7209 — each bridge reads SchemaRendererContext as declared', () => { + for (const owner of SITES) { + it(`\`${owner}\`: the hook call carries the declared context type, not \`any\``, () => { + const { declared: d, sites } = measurement(); + expect({ owner, type: sites[owner].type, isAny: sites[owner].isAny }) + .toEqual({ owner, type: d.type, isAny: false }); + }); + + it(`\`${owner}\`: PROBE — the phantom \`${PHANTOM}\` member is not readable off the value`, () => { + expect(measurement().sites[owner].phantomReadable).toBe(false); + }); + + it(`\`${owner}\`: LIT CONTROL — \`dataSource\` reads at its declared type`, () => { + const { declared: d, sites } = measurement(); + expect(sites[owner].dataSource).toBe(d.dataSource); + }); + } +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index bf28bef446..059806a71c 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -310,7 +310,15 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock // nothing to bind it to, rendering a field-less shell. That is the // objectstack#4413 shape; `public-block-binding-reach.test.tsx` is what // catches it now. - const ctx = useContext(SchemaRendererContext as React.Context); + // + // Read AS DECLARED (objectui#7209): no cast between the hook and the value, so + // a member `SchemaRendererContextType` does not declare is a compile error + // here rather than a silent `undefined` — pinned for both readers in this + // file by `schemaRendererContextRead-7209.test.ts`. The declared `null` (no + // adapter bound) collapses to the one absence `EmbeddableForm`'s optional + // prop declares, exactly as `MasterDetailFormRenderer` below already does. + const ctx = useContext(SchemaRendererContext); + const dataSource = ctx?.dataSource ?? undefined; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). Same // standing, and the same single mapped key, as `object-form` above: // `EmbeddableForm` reads `config.objectName` to fetch the object's fields and @@ -325,11 +333,11 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock return ( - {(bound) => } + {(bound) => } ); }); @@ -371,7 +379,7 @@ ComponentRegistry.register('form-analytics', FormAnalyticsRenderer, { import { MasterDetailForm } from './MasterDetailForm'; const MasterDetailFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => { - const ctx = useContext(SchemaRendererContext as React.Context); + const ctx = useContext(SchemaRendererContext); const dataSource = ctx?.dataSource ?? undefined; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). // `schema.objectName` is the PARENT object here, and everything downstream is diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 3af6bf68a7..5d3a15525e 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -99,7 +99,9 @@ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = { * remove, and the one an AI-authored page hides best. */ const ListViewBlock = elementDataSourceBlock(React.forwardRef((props, ref) => { - const context = useContext(SchemaRendererContext as React.Context); + // Read AS DECLARED (objectui#7209) — no cast, so an undeclared member is a + // compile error here. Pinned by `ListViewBlock.schemaRendererContextRead-7209.test.ts`. + const context = useContext(SchemaRendererContext); // Defence in depth for the collision above: even though SchemaRenderer no // longer spreads it, a host (or an older cached bundle) handing us the spec diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.schemaRendererContextRead-7209.test.ts b/packages/plugin-list/src/__tests__/ListViewBlock.schemaRendererContextRead-7209.test.ts new file mode 100644 index 0000000000..1a129dff93 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.schemaRendererContextRead-7209.test.ts @@ -0,0 +1,227 @@ +/** + * 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#7209 — `ListViewBlock` reads `SchemaRendererContext` AS DECLARED. + * + * It used to read it through + * `useContext(SchemaRendererContext as React.Context)`, which erased + * `SchemaRendererContextType` at the read: a member the context does not + * declare type-checked clean — the mechanism behind objectui#7206's phantom + * `ctx.formValues ?? ctx.data` channel. + * + * The harness, and why it is a `ts.Program` rather than a `@ts-expect-error` + * in a test file, is explained once in plugin-form's + * `schemaRendererContextRead-7209.test.ts`; this file is the same harness over + * this package's site. In short: the DECLARED type is read off + * `@object-ui/react`'s own export in the same program; the site is located by + * NAME and must hold exactly one read; a cast planted back IN MEMORY must read + * `any` (so the verdicts below can fail); `formValues`, the member + * objectui#7206 retired, must not be readable (PROBE); `dataSource` must read + * at its declared type (LIT CONTROL). + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const slash = (p: string): string => p.replace(/\\/g, '/'); +const SITE_FILE = slash(join(HERE, '..', 'ListViewBlock.tsx')); +const CONTEXT_FILE = slash(join(REPO_ROOT, 'packages', 'react', 'src', 'context', 'SchemaRendererContext.tsx')); + +/** The registry block that reads the context, by the name it is declared under. */ +const SITES = ['ListViewBlock'] as const; +/** The member objectui#7206 retired — it never existed on the context. */ +const PHANTOM = 'formValues'; +/** What the calibration leg plants back: the cast this card removed. */ +const PLANTED_CAST = " as import('react').Context"; + +interface Reading { + type: string; + isAny: boolean; + phantomReadable: boolean; + /** The `dataSource` member's type, or null when the value has no such member. */ + dataSource: string | null; +} + +/** + * A program over the site file, optionally with the site's text replaced in + * memory. Every other file is parsed once and shared between the two programs + * `measure()` builds, so the calibration program costs one file, not the tree. + */ +function programFactory(): (siteText?: string) => ts.Program { + const configPath = join(REPO_ROOT, 'tsconfig.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, ' ')); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, REPO_ROOT, undefined, configPath); + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + skipLibCheck: true, + types: [], + paths: { ...parsed.options.paths, '@object-ui/react': ['packages/react/src'] }, + }; + const host = ts.createCompilerHost(options); + const parse = host.getSourceFile.bind(host); + const parsedFiles = new Map(); + let override: string | undefined; + host.getSourceFile = (fileName, languageVersion, ...rest) => { + if (override !== undefined && slash(fileName) === SITE_FILE) { + return ts.createSourceFile(fileName, override, languageVersion, true); + } + if (!parsedFiles.has(fileName)) parsedFiles.set(fileName, parse(fileName, languageVersion, ...rest)); + return parsedFiles.get(fileName); + }; + return (siteText) => { + override = siteText; + return ts.createProgram([SITE_FILE], options, host); + }; +} + +/** The one `useContext(SchemaRendererContext…)` call inside the named declaration. */ +function hookCall(program: ts.Program, owner: string): ts.CallExpression { + const sf = program.getSourceFile(SITE_FILE); + if (!sf) throw new Error(`setup failure: ${SITE_FILE} is not a program input`); + let ownerNode: ts.Node | undefined; + const findOwner = (node: ts.Node): void => { + if (ownerNode) return; + if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) + && node.name && ts.isIdentifier(node.name) && node.name.text === owner) { + ownerNode = node; + return; + } + ts.forEachChild(node, findOwner); + }; + findOwner(sf); + if (!ownerNode) throw new Error(`setup failure: no declaration named \`${owner}\` in ${SITE_FILE}`); + const calls: ts.CallExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && /(^|\.)useContext$/.test(node.expression.getText(sf)) + && node.arguments.length === 1 + && /\bSchemaRendererContext\b/.test(node.arguments[0].getText(sf))) { + calls.push(node); + } + ts.forEachChild(node, walk); + }; + walk(ownerNode); + if (calls.length !== 1) { + throw new Error(`setup failure: \`${owner}\` holds ${calls.length} reads of SchemaRendererContext, expected exactly 1`); + } + return calls[0]; +} + +function describeValue(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): Reading { + const isAny = (type.flags & ts.TypeFlags.Any) !== 0; + const value = checker.getNonNullableType(type); + const member = checker.getPropertyOfType(value, 'dataSource'); + return { + type: checker.typeToString(type), + isAny, + phantomReadable: isAny + || checker.getPropertyOfType(value, PHANTOM) !== undefined + || checker.getIndexInfosOfType(value).length > 0, + dataSource: member ? checker.typeToString(checker.getTypeOfSymbolAtLocation(member, at)) : null, + }; +} + +/** `SchemaRendererContextType | null` as `@object-ui/react` exports it. */ +function declared(program: ts.Program): Reading { + const checker = program.getTypeChecker(); + const sf = program.getSourceFile(CONTEXT_FILE); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol) + .find((s) => s.name === 'SchemaRendererContext'); + if (!sf || !exported) throw new Error('setup failure: `SchemaRendererContext` did not resolve to its source export'); + const [valueType] = checker.getTypeArguments(checker.getTypeOfSymbol(exported) as ts.TypeReference); + if (!valueType) throw new Error('setup failure: the context export carries no type argument'); + return describeValue(checker, valueType, sf); +} + +function readSites(program: ts.Program): Record { + const checker = program.getTypeChecker(); + return Object.fromEntries(SITES.map((owner) => { + const call = hookCall(program, owner); + return [owner, describeValue(checker, checker.getTypeAtLocation(call), call)]; + })); +} + +interface Measurement { + declared: Reading; + sites: Record; + planted: Record; +} + +function measure(): Measurement { + const build = programFactory(); + const program = build(); + const sf = program.getSourceFile(SITE_FILE)!; + // Plant the cast after each context argument, last position first so the + // earlier offsets stay valid. + const ends = SITES.map((owner) => hookCall(program, owner).arguments[0].end).sort((a, b) => b - a); + let planted = sf.text; + for (const end of ends) planted = planted.slice(0, end) + PLANTED_CAST + planted.slice(end); + return { + declared: declared(program), + sites: readSites(program), + planted: readSites(build(planted)), + }; +} + +// MODULE SCOPE on purpose (AGENTS.md 测试纪律): the compiler work lands in the +// import phase, which no test or hook timeout bounds. A failure is kept and +// re-thrown by every test that reads the measurement, so it surfaces as named +// failures rather than as a collection error. +let outcome: Measurement | Error; +try { + outcome = measure(); +} catch (error) { + outcome = error instanceof Error ? error : new Error(String(error)); +} +const measurement = (): Measurement => { + if (outcome instanceof Error) throw outcome; + return outcome; +}; + +describe('objectui#7209 — the instrument is calibrated before it is believed', () => { + it('resolved the declared context type from `@object-ui/react`’s own export', () => { + const { declared: d } = measurement(); + expect(d.isAny).toBe(false); + expect(d.phantomReadable).toBe(false); + expect(d.dataSource).toMatch(/\bDataSource\b/); + }); + + for (const owner of SITES) { + it(`\`${owner}\` with the cast planted back IN MEMORY reads \`any\` — the verdicts below can fail`, () => { + expect(measurement().planted[owner]).toMatchObject({ isAny: true, phantomReadable: true, dataSource: null }); + }); + } +}); + +describe('objectui#7209 — each bridge reads SchemaRendererContext as declared', () => { + for (const owner of SITES) { + it(`\`${owner}\`: the hook call carries the declared context type, not \`any\``, () => { + const { declared: d, sites } = measurement(); + expect({ owner, type: sites[owner].type, isAny: sites[owner].isAny }) + .toEqual({ owner, type: d.type, isAny: false }); + }); + + it(`\`${owner}\`: PROBE — the phantom \`${PHANTOM}\` member is not readable off the value`, () => { + expect(measurement().sites[owner].phantomReadable).toBe(false); + }); + + it(`\`${owner}\`: LIT CONTROL — \`dataSource\` reads at its declared type`, () => { + const { declared: d, sites } = measurement(); + expect(sites[owner].dataSource).toBe(d.dataSource); + }); + } +}); diff --git a/packages/plugin-view/src/__tests__/objectViewRenderer.schemaRendererContextRead-7209.test.ts b/packages/plugin-view/src/__tests__/objectViewRenderer.schemaRendererContextRead-7209.test.ts new file mode 100644 index 0000000000..f0fc5ee9c3 --- /dev/null +++ b/packages/plugin-view/src/__tests__/objectViewRenderer.schemaRendererContextRead-7209.test.ts @@ -0,0 +1,231 @@ +/** + * 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#7209 — `ObjectViewRenderer` reads `SchemaRendererContext` AS + * DECLARED. + * + * This module used to re-declare the imported context at module scope as a + * `React.Context`, which erased `SchemaRendererContextType` for every read + * of it: a member the context does not declare type-checked clean — the + * mechanism behind objectui#7206's phantom `ctx.formValues ?? ctx.data` + * channel. The re-declaration is gone and the hook reads the import directly; + * the one value that genuinely disagrees with a consumer (`null` handed to the + * REQUIRED `ObjectViewProps.dataSource`, objectui#7842) now carries its own + * narrow assertion at that value instead of an erasure of the whole context. + * + * The harness, and why it is a `ts.Program` rather than a `@ts-expect-error` + * in a test file, is explained once in plugin-form's + * `schemaRendererContextRead-7209.test.ts`; this file is the same harness over + * this package's site. In short: the DECLARED type is read off + * `@object-ui/react`'s own export in the same program; the site is located by + * NAME and must hold exactly one read; a cast planted back IN MEMORY must read + * `any` (so the verdicts below can fail); `formValues`, the member + * objectui#7206 retired, must not be readable (PROBE); `dataSource` must read + * at its declared type (LIT CONTROL). + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const slash = (p: string): string => p.replace(/\\/g, '/'); +const SITE_FILE = slash(join(HERE, '..', 'index.tsx')); +const CONTEXT_FILE = slash(join(REPO_ROOT, 'packages', 'react', 'src', 'context', 'SchemaRendererContext.tsx')); + +/** The renderer that reads the context, by the name it is declared under. */ +const SITES = ['ObjectViewRenderer'] as const; +/** The member objectui#7206 retired — it never existed on the context. */ +const PHANTOM = 'formValues'; +/** What the calibration leg plants back: the cast this card removed. */ +const PLANTED_CAST = " as import('react').Context"; + +interface Reading { + type: string; + isAny: boolean; + phantomReadable: boolean; + /** The `dataSource` member's type, or null when the value has no such member. */ + dataSource: string | null; +} + +/** + * A program over the site file, optionally with the site's text replaced in + * memory. Every other file is parsed once and shared between the two programs + * `measure()` builds, so the calibration program costs one file, not the tree. + */ +function programFactory(): (siteText?: string) => ts.Program { + const configPath = join(REPO_ROOT, 'tsconfig.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, ' ')); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, REPO_ROOT, undefined, configPath); + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + skipLibCheck: true, + types: [], + paths: { ...parsed.options.paths, '@object-ui/react': ['packages/react/src'] }, + }; + const host = ts.createCompilerHost(options); + const parse = host.getSourceFile.bind(host); + const parsedFiles = new Map(); + let override: string | undefined; + host.getSourceFile = (fileName, languageVersion, ...rest) => { + if (override !== undefined && slash(fileName) === SITE_FILE) { + return ts.createSourceFile(fileName, override, languageVersion, true); + } + if (!parsedFiles.has(fileName)) parsedFiles.set(fileName, parse(fileName, languageVersion, ...rest)); + return parsedFiles.get(fileName); + }; + return (siteText) => { + override = siteText; + return ts.createProgram([SITE_FILE], options, host); + }; +} + +/** The one `useContext(SchemaRendererContext…)` call inside the named declaration. */ +function hookCall(program: ts.Program, owner: string): ts.CallExpression { + const sf = program.getSourceFile(SITE_FILE); + if (!sf) throw new Error(`setup failure: ${SITE_FILE} is not a program input`); + let ownerNode: ts.Node | undefined; + const findOwner = (node: ts.Node): void => { + if (ownerNode) return; + if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) + && node.name && ts.isIdentifier(node.name) && node.name.text === owner) { + ownerNode = node; + return; + } + ts.forEachChild(node, findOwner); + }; + findOwner(sf); + if (!ownerNode) throw new Error(`setup failure: no declaration named \`${owner}\` in ${SITE_FILE}`); + const calls: ts.CallExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && /(^|\.)useContext$/.test(node.expression.getText(sf)) + && node.arguments.length === 1 + && /\bSchemaRendererContext\b/.test(node.arguments[0].getText(sf))) { + calls.push(node); + } + ts.forEachChild(node, walk); + }; + walk(ownerNode); + if (calls.length !== 1) { + throw new Error(`setup failure: \`${owner}\` holds ${calls.length} reads of SchemaRendererContext, expected exactly 1`); + } + return calls[0]; +} + +function describeValue(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): Reading { + const isAny = (type.flags & ts.TypeFlags.Any) !== 0; + const value = checker.getNonNullableType(type); + const member = checker.getPropertyOfType(value, 'dataSource'); + return { + type: checker.typeToString(type), + isAny, + phantomReadable: isAny + || checker.getPropertyOfType(value, PHANTOM) !== undefined + || checker.getIndexInfosOfType(value).length > 0, + dataSource: member ? checker.typeToString(checker.getTypeOfSymbolAtLocation(member, at)) : null, + }; +} + +/** `SchemaRendererContextType | null` as `@object-ui/react` exports it. */ +function declared(program: ts.Program): Reading { + const checker = program.getTypeChecker(); + const sf = program.getSourceFile(CONTEXT_FILE); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol) + .find((s) => s.name === 'SchemaRendererContext'); + if (!sf || !exported) throw new Error('setup failure: `SchemaRendererContext` did not resolve to its source export'); + const [valueType] = checker.getTypeArguments(checker.getTypeOfSymbol(exported) as ts.TypeReference); + if (!valueType) throw new Error('setup failure: the context export carries no type argument'); + return describeValue(checker, valueType, sf); +} + +function readSites(program: ts.Program): Record { + const checker = program.getTypeChecker(); + return Object.fromEntries(SITES.map((owner) => { + const call = hookCall(program, owner); + return [owner, describeValue(checker, checker.getTypeAtLocation(call), call)]; + })); +} + +interface Measurement { + declared: Reading; + sites: Record; + planted: Record; +} + +function measure(): Measurement { + const build = programFactory(); + const program = build(); + const sf = program.getSourceFile(SITE_FILE)!; + // Plant the cast after each context argument, last position first so the + // earlier offsets stay valid. + const ends = SITES.map((owner) => hookCall(program, owner).arguments[0].end).sort((a, b) => b - a); + let planted = sf.text; + for (const end of ends) planted = planted.slice(0, end) + PLANTED_CAST + planted.slice(end); + return { + declared: declared(program), + sites: readSites(program), + planted: readSites(build(planted)), + }; +} + +// MODULE SCOPE on purpose (AGENTS.md 测试纪律): the compiler work lands in the +// import phase, which no test or hook timeout bounds. A failure is kept and +// re-thrown by every test that reads the measurement, so it surfaces as named +// failures rather than as a collection error. +let outcome: Measurement | Error; +try { + outcome = measure(); +} catch (error) { + outcome = error instanceof Error ? error : new Error(String(error)); +} +const measurement = (): Measurement => { + if (outcome instanceof Error) throw outcome; + return outcome; +}; + +describe('objectui#7209 — the instrument is calibrated before it is believed', () => { + it('resolved the declared context type from `@object-ui/react`’s own export', () => { + const { declared: d } = measurement(); + expect(d.isAny).toBe(false); + expect(d.phantomReadable).toBe(false); + expect(d.dataSource).toMatch(/\bDataSource\b/); + }); + + for (const owner of SITES) { + it(`\`${owner}\` with the cast planted back IN MEMORY reads \`any\` — the verdicts below can fail`, () => { + expect(measurement().planted[owner]).toMatchObject({ isAny: true, phantomReadable: true, dataSource: null }); + }); + } +}); + +describe('objectui#7209 — each bridge reads SchemaRendererContext as declared', () => { + for (const owner of SITES) { + it(`\`${owner}\`: the hook call carries the declared context type, not \`any\``, () => { + const { declared: d, sites } = measurement(); + expect({ owner, type: sites[owner].type, isAny: sites[owner].isAny }) + .toEqual({ owner, type: d.type, isAny: false }); + }); + + it(`\`${owner}\`: PROBE — the phantom \`${PHANTOM}\` member is not readable off the value`, () => { + expect(measurement().sites[owner].phantomReadable).toBe(false); + }); + + it(`\`${owner}\`: LIT CONTROL — \`dataSource\` reads at its declared type`, () => { + const { declared: d, sites } = measurement(); + expect(sites[owner].dataSource).toBe(d.dataSource); + }); + } +}); diff --git a/packages/plugin-view/src/index.tsx b/packages/plugin-view/src/index.tsx index d131f5aa93..d70dbed39d 100644 --- a/packages/plugin-view/src/index.tsx +++ b/packages/plugin-view/src/index.tsx @@ -8,7 +8,8 @@ import React, { useContext } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react'; +import { SchemaRendererContext } from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; import { ObjectView } from './ObjectView'; import { ViewSwitcher } from './ViewSwitcher'; import { FilterUI } from './FilterUI'; @@ -48,17 +49,21 @@ export { } from './config/view-config-utils'; export type { FieldOption } from './config/view-config-utils'; -/** - * SchemaRendererContext is created by @object-ui/react. - * The context value provides { dataSource }. - */ -const SchemaRendererContext: React.Context = ImportedSchemaRendererContext; - // Register object-view component const ObjectViewRenderer: React.FC<{ schema: any }> = ({ schema }) => { - // Resolve dataSource from SchemaRendererProvider context + // Resolve dataSource from SchemaRendererProvider context, read AS DECLARED + // (objectui#7209). This module used to re-declare the imported context as a + // `Context` of `any`, which let every read of it — including a member the + // context does not declare — compile clean. Pinned by + // `objectViewRenderer.schemaRendererContextRead-7209.test.ts`. const ctx = useContext(SchemaRendererContext); - const dataSource = ctx?.dataSource ?? null; + // What that erasure also hid here, now spelled out at this one value instead + // of across the whole context: with no adapter bound this renderer hands + // `null` to `ObjectViewProps.dataSource`, which is declared REQUIRED and stays + // so (objectui#7842 — widening it is a maintainer's ruling). `ObjectView` + // guards every use of a missing adapter and renders its chrome empty; its prop + // documentation names this renderer as that `null` caller. + const dataSource = (ctx?.dataSource ?? null) as DataSource; return ; }; diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index 2db3dd44d8..212f6039f2 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -495,7 +495,10 @@ export function ElementDataSourceLoadingPanel({ * it off and you get `unknown`, which is the honest default. */ export function useResolvedDataSource(explicit?: unknown): T | undefined { - const context = React.useContext(SchemaRendererContext as React.Context); + // Read AS DECLARED (objectui#7209): the only cast in this hook is the + // caller's `T` on the way out, never one erasing the context on the way in. + // Pinned by `useResolvedDataSource.schemaRendererContextRead-7209.test.ts`. + const context = React.useContext(SchemaRendererContext); const named = isElementDataSourceConfig(explicit) ? undefined : explicit; return (named ?? context?.dataSource ?? undefined) as T | undefined; } diff --git a/packages/react/src/element-data-source/__tests__/useResolvedDataSource.schemaRendererContextRead-7209.test.ts b/packages/react/src/element-data-source/__tests__/useResolvedDataSource.schemaRendererContextRead-7209.test.ts new file mode 100644 index 0000000000..c883101f57 --- /dev/null +++ b/packages/react/src/element-data-source/__tests__/useResolvedDataSource.schemaRendererContextRead-7209.test.ts @@ -0,0 +1,229 @@ +/** + * 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#7209 — `useResolvedDataSource` reads `SchemaRendererContext` AS + * DECLARED. + * + * It used to read it through + * `React.useContext(SchemaRendererContext as React.Context)`, which erased + * `SchemaRendererContextType` at the read: a member the context does not + * declare type-checked clean — the mechanism behind objectui#7206's phantom + * `ctx.formValues ?? ctx.data` channel. The hook's published signature is + * unchanged; its only remaining assertion is the caller's `T` on the way out. + * + * The harness, and why it is a `ts.Program` rather than a `@ts-expect-error` + * in a test file, is explained once in plugin-form's + * `schemaRendererContextRead-7209.test.ts`; this file is the same harness over + * this package's site. In short: the DECLARED type is read off + * `@object-ui/react`'s own export in the same program; the site is located by + * NAME and must hold exactly one read; a cast planted back IN MEMORY must read + * `any` (so the verdicts below can fail); `formValues`, the member + * objectui#7206 retired, must not be readable (PROBE); `dataSource` must read + * at its declared type (LIT CONTROL). + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..'); +const slash = (p: string): string => p.replace(/\\/g, '/'); +const SITE_FILE = slash(join(HERE, '..', 'ElementDataSourceGate.tsx')); +const CONTEXT_FILE = slash(join(REPO_ROOT, 'packages', 'react', 'src', 'context', 'SchemaRendererContext.tsx')); + +/** The exported hook that reads the context, by the name it is declared under. */ +const SITES = ['useResolvedDataSource'] as const; +/** The member objectui#7206 retired — it never existed on the context. */ +const PHANTOM = 'formValues'; +/** What the calibration leg plants back: the cast this card removed. */ +const PLANTED_CAST = " as import('react').Context"; + +interface Reading { + type: string; + isAny: boolean; + phantomReadable: boolean; + /** The `dataSource` member's type, or null when the value has no such member. */ + dataSource: string | null; +} + +/** + * A program over the site file, optionally with the site's text replaced in + * memory. Every other file is parsed once and shared between the two programs + * `measure()` builds, so the calibration program costs one file, not the tree. + */ +function programFactory(): (siteText?: string) => ts.Program { + const configPath = join(REPO_ROOT, 'tsconfig.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, ' ')); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, REPO_ROOT, undefined, configPath); + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + skipLibCheck: true, + types: [], + paths: { ...parsed.options.paths, '@object-ui/react': ['packages/react/src'] }, + }; + const host = ts.createCompilerHost(options); + const parse = host.getSourceFile.bind(host); + const parsedFiles = new Map(); + let override: string | undefined; + host.getSourceFile = (fileName, languageVersion, ...rest) => { + if (override !== undefined && slash(fileName) === SITE_FILE) { + return ts.createSourceFile(fileName, override, languageVersion, true); + } + if (!parsedFiles.has(fileName)) parsedFiles.set(fileName, parse(fileName, languageVersion, ...rest)); + return parsedFiles.get(fileName); + }; + return (siteText) => { + override = siteText; + return ts.createProgram([SITE_FILE], options, host); + }; +} + +/** The one `useContext(SchemaRendererContext…)` call inside the named declaration. */ +function hookCall(program: ts.Program, owner: string): ts.CallExpression { + const sf = program.getSourceFile(SITE_FILE); + if (!sf) throw new Error(`setup failure: ${SITE_FILE} is not a program input`); + let ownerNode: ts.Node | undefined; + const findOwner = (node: ts.Node): void => { + if (ownerNode) return; + if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) + && node.name && ts.isIdentifier(node.name) && node.name.text === owner) { + ownerNode = node; + return; + } + ts.forEachChild(node, findOwner); + }; + findOwner(sf); + if (!ownerNode) throw new Error(`setup failure: no declaration named \`${owner}\` in ${SITE_FILE}`); + const calls: ts.CallExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && /(^|\.)useContext$/.test(node.expression.getText(sf)) + && node.arguments.length === 1 + && /\bSchemaRendererContext\b/.test(node.arguments[0].getText(sf))) { + calls.push(node); + } + ts.forEachChild(node, walk); + }; + walk(ownerNode); + if (calls.length !== 1) { + throw new Error(`setup failure: \`${owner}\` holds ${calls.length} reads of SchemaRendererContext, expected exactly 1`); + } + return calls[0]; +} + +function describeValue(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): Reading { + const isAny = (type.flags & ts.TypeFlags.Any) !== 0; + const value = checker.getNonNullableType(type); + const member = checker.getPropertyOfType(value, 'dataSource'); + return { + type: checker.typeToString(type), + isAny, + phantomReadable: isAny + || checker.getPropertyOfType(value, PHANTOM) !== undefined + || checker.getIndexInfosOfType(value).length > 0, + dataSource: member ? checker.typeToString(checker.getTypeOfSymbolAtLocation(member, at)) : null, + }; +} + +/** `SchemaRendererContextType | null` as `@object-ui/react` exports it. */ +function declared(program: ts.Program): Reading { + const checker = program.getTypeChecker(); + const sf = program.getSourceFile(CONTEXT_FILE); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol) + .find((s) => s.name === 'SchemaRendererContext'); + if (!sf || !exported) throw new Error('setup failure: `SchemaRendererContext` did not resolve to its source export'); + const [valueType] = checker.getTypeArguments(checker.getTypeOfSymbol(exported) as ts.TypeReference); + if (!valueType) throw new Error('setup failure: the context export carries no type argument'); + return describeValue(checker, valueType, sf); +} + +function readSites(program: ts.Program): Record { + const checker = program.getTypeChecker(); + return Object.fromEntries(SITES.map((owner) => { + const call = hookCall(program, owner); + return [owner, describeValue(checker, checker.getTypeAtLocation(call), call)]; + })); +} + +interface Measurement { + declared: Reading; + sites: Record; + planted: Record; +} + +function measure(): Measurement { + const build = programFactory(); + const program = build(); + const sf = program.getSourceFile(SITE_FILE)!; + // Plant the cast after each context argument, last position first so the + // earlier offsets stay valid. + const ends = SITES.map((owner) => hookCall(program, owner).arguments[0].end).sort((a, b) => b - a); + let planted = sf.text; + for (const end of ends) planted = planted.slice(0, end) + PLANTED_CAST + planted.slice(end); + return { + declared: declared(program), + sites: readSites(program), + planted: readSites(build(planted)), + }; +} + +// MODULE SCOPE on purpose (AGENTS.md 测试纪律): the compiler work lands in the +// import phase, which no test or hook timeout bounds. A failure is kept and +// re-thrown by every test that reads the measurement, so it surfaces as named +// failures rather than as a collection error. +let outcome: Measurement | Error; +try { + outcome = measure(); +} catch (error) { + outcome = error instanceof Error ? error : new Error(String(error)); +} +const measurement = (): Measurement => { + if (outcome instanceof Error) throw outcome; + return outcome; +}; + +describe('objectui#7209 — the instrument is calibrated before it is believed', () => { + it('resolved the declared context type from `@object-ui/react`’s own export', () => { + const { declared: d } = measurement(); + expect(d.isAny).toBe(false); + expect(d.phantomReadable).toBe(false); + expect(d.dataSource).toMatch(/\bDataSource\b/); + }); + + for (const owner of SITES) { + it(`\`${owner}\` with the cast planted back IN MEMORY reads \`any\` — the verdicts below can fail`, () => { + expect(measurement().planted[owner]).toMatchObject({ isAny: true, phantomReadable: true, dataSource: null }); + }); + } +}); + +describe('objectui#7209 — each bridge reads SchemaRendererContext as declared', () => { + for (const owner of SITES) { + it(`\`${owner}\`: the hook call carries the declared context type, not \`any\``, () => { + const { declared: d, sites } = measurement(); + expect({ owner, type: sites[owner].type, isAny: sites[owner].isAny }) + .toEqual({ owner, type: d.type, isAny: false }); + }); + + it(`\`${owner}\`: PROBE — the phantom \`${PHANTOM}\` member is not readable off the value`, () => { + expect(measurement().sites[owner].phantomReadable).toBe(false); + }); + + it(`\`${owner}\`: LIT CONTROL — \`dataSource\` reads at its declared type`, () => { + const { declared: d, sites } = measurement(); + expect(sites[owner].dataSource).toBe(d.dataSource); + }); + } +}); From a879ffe6a61f17a3461acf5400b535c6cfbad93d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 19:30:31 +0000 Subject: [PATCH 2/2] fix(react): useElementDataSource reads SchemaRendererContext as declared The sixth type-erasing cast of this class: `useElementDataSource` read the context through `useContext(SchemaRendererContext as Context)`, spelled with the bare `Context` type imported from react, which the first census (keyed on `React.Context`) did not match. The cast and the now-unused `Context` type import are gone; the hook call now carries `SchemaRendererContextType | null`. Nothing else in the react program moves (0 diagnostics), and no runtime value changes. Pinned beside it by the same ts.Program harness as the other five sites; the plugin-form pin's sibling list and the changeset now name it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...chema-renderer-context-read-as-declared.md | 16 +- .../schemaRendererContextRead-7209.test.ts | 5 +- ...rce.schemaRendererContextRead-7209.test.ts | 231 ++++++++++++++++++ .../react/src/hooks/useElementDataSource.ts | 7 +- 4 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 packages/react/src/hooks/__tests__/useElementDataSource.schemaRendererContextRead-7209.test.ts diff --git a/.changeset/7209-schema-renderer-context-read-as-declared.md b/.changeset/7209-schema-renderer-context-read-as-declared.md index d1c7273bf8..01cb773ef2 100644 --- a/.changeset/7209-schema-renderer-context-read-as-declared.md +++ b/.changeset/7209-schema-renderer-context-read-as-declared.md @@ -7,14 +7,15 @@ fix(plugin-form,plugin-list,plugin-view,react): read `SchemaRendererContext` as declared, not through a cast to `any` (objectui#7209) -Five reads of the renderer context erased its type. Four cast at the read — +Six reads of the renderer context erased its type. Five cast at the read — `useContext(SchemaRendererContext as React.Context)` in the `embeddable-form` and `object-master-detail-form` bridges, in `ListViewBlock` -and in `useResolvedDataSource` — and the `object-view` renderer's module -re-declared the imported context as a `React.Context`. Through that cast a -read of a member the context does not declare compiled clean, which is how the -phantom `ctx.formValues ?? ctx.data` channel retired by objectui#7206 went -unnoticed. Each of these reads now sees `SchemaRendererContextType` as +and in `useResolvedDataSource`, and the same cast spelled with a bare +`Context` in `useElementDataSource` — and the `object-view` renderer's +module re-declared the imported context as a `React.Context`. Through +that cast a read of a member the context does not declare compiled clean, +which is how the phantom `ctx.formValues ?? ctx.data` channel retired by +objectui#7206 went unnoticed. Each of these reads now sees `SchemaRendererContextType` as `@object-ui/react` declares it, so such a read is a compile error on the day it is written. @@ -31,4 +32,5 @@ adapter bound") met a prop that does not admit it: narrow, commented assertion instead of the whole context being erased; the value passed is the same as before. -The published signature of `useResolvedDataSource` does not move. +The published signatures of `useResolvedDataSource` and `useElementDataSource` +do not move. diff --git a/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts b/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts index 877face52b..eab8b37984 100644 --- a/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts +++ b/packages/plugin-form/src/__tests__/schemaRendererContextRead-7209.test.ts @@ -19,8 +19,9 @@ * * Sibling pins for the card's other sites, each over its own package's file: * `ListViewBlock.schemaRendererContextRead-7209.test.ts` (plugin-list), - * `objectViewRenderer.schemaRendererContextRead-7209.test.ts` (plugin-view) and - * `useResolvedDataSource.schemaRendererContextRead-7209.test.ts` (react). + * `objectViewRenderer.schemaRendererContextRead-7209.test.ts` (plugin-view), + * `useResolvedDataSource.schemaRendererContextRead-7209.test.ts` and + * `useElementDataSource.schemaRendererContextRead-7209.test.ts` (react). * * ## Why a program, and not a `@ts-expect-error` in this file * diff --git a/packages/react/src/hooks/__tests__/useElementDataSource.schemaRendererContextRead-7209.test.ts b/packages/react/src/hooks/__tests__/useElementDataSource.schemaRendererContextRead-7209.test.ts new file mode 100644 index 0000000000..0f4b5c5401 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useElementDataSource.schemaRendererContextRead-7209.test.ts @@ -0,0 +1,231 @@ +/** + * 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#7209 — `useElementDataSource` reads `SchemaRendererContext` AS + * DECLARED. + * + * It used to read it through `useContext(SchemaRendererContext as Context)` + * — spelled with the bare `Context` type imported from `react`, which is why + * this card's first census, keyed on `React.Context`, missed it. + * That cast erased `SchemaRendererContextType` at the read: a member the + * context does not declare type-checked clean — the mechanism behind + * objectui#7206's phantom `ctx.formValues ?? ctx.data` channel. The hook's + * published signature is unchanged; its only remaining assertion is the + * adapter's `ViewCapableDataSource` on the way out. + * + * The harness, and why it is a `ts.Program` rather than a `@ts-expect-error` + * in a test file, is explained once in plugin-form's + * `schemaRendererContextRead-7209.test.ts`; this file is the same harness over + * this package's site. In short: the DECLARED type is read off + * `@object-ui/react`'s own export in the same program; the site is located by + * NAME and must hold exactly one read; a cast planted back IN MEMORY must read + * `any` (so the verdicts below can fail); `formValues`, the member + * objectui#7206 retired, must not be readable (PROBE); `dataSource` must read + * at its declared type (LIT CONTROL). + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..'); +const slash = (p: string): string => p.replace(/\\/g, '/'); +const SITE_FILE = slash(join(HERE, '..', 'useElementDataSource.ts')); +const CONTEXT_FILE = slash(join(REPO_ROOT, 'packages', 'react', 'src', 'context', 'SchemaRendererContext.tsx')); + +/** The exported hook that reads the context, by the name it is declared under. */ +const SITES = ['useElementDataSource'] as const; +/** The member objectui#7206 retired — it never existed on the context. */ +const PHANTOM = 'formValues'; +/** What the calibration leg plants back: the cast this card removed. */ +const PLANTED_CAST = " as import('react').Context"; + +interface Reading { + type: string; + isAny: boolean; + phantomReadable: boolean; + /** The `dataSource` member's type, or null when the value has no such member. */ + dataSource: string | null; +} + +/** + * A program over the site file, optionally with the site's text replaced in + * memory. Every other file is parsed once and shared between the two programs + * `measure()` builds, so the calibration program costs one file, not the tree. + */ +function programFactory(): (siteText?: string) => ts.Program { + const configPath = join(REPO_ROOT, 'tsconfig.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, ' ')); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, REPO_ROOT, undefined, configPath); + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + skipLibCheck: true, + types: [], + paths: { ...parsed.options.paths, '@object-ui/react': ['packages/react/src'] }, + }; + const host = ts.createCompilerHost(options); + const parse = host.getSourceFile.bind(host); + const parsedFiles = new Map(); + let override: string | undefined; + host.getSourceFile = (fileName, languageVersion, ...rest) => { + if (override !== undefined && slash(fileName) === SITE_FILE) { + return ts.createSourceFile(fileName, override, languageVersion, true); + } + if (!parsedFiles.has(fileName)) parsedFiles.set(fileName, parse(fileName, languageVersion, ...rest)); + return parsedFiles.get(fileName); + }; + return (siteText) => { + override = siteText; + return ts.createProgram([SITE_FILE], options, host); + }; +} + +/** The one `useContext(SchemaRendererContext…)` call inside the named declaration. */ +function hookCall(program: ts.Program, owner: string): ts.CallExpression { + const sf = program.getSourceFile(SITE_FILE); + if (!sf) throw new Error(`setup failure: ${SITE_FILE} is not a program input`); + let ownerNode: ts.Node | undefined; + const findOwner = (node: ts.Node): void => { + if (ownerNode) return; + if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) + && node.name && ts.isIdentifier(node.name) && node.name.text === owner) { + ownerNode = node; + return; + } + ts.forEachChild(node, findOwner); + }; + findOwner(sf); + if (!ownerNode) throw new Error(`setup failure: no declaration named \`${owner}\` in ${SITE_FILE}`); + const calls: ts.CallExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && /(^|\.)useContext$/.test(node.expression.getText(sf)) + && node.arguments.length === 1 + && /\bSchemaRendererContext\b/.test(node.arguments[0].getText(sf))) { + calls.push(node); + } + ts.forEachChild(node, walk); + }; + walk(ownerNode); + if (calls.length !== 1) { + throw new Error(`setup failure: \`${owner}\` holds ${calls.length} reads of SchemaRendererContext, expected exactly 1`); + } + return calls[0]; +} + +function describeValue(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): Reading { + const isAny = (type.flags & ts.TypeFlags.Any) !== 0; + const value = checker.getNonNullableType(type); + const member = checker.getPropertyOfType(value, 'dataSource'); + return { + type: checker.typeToString(type), + isAny, + phantomReadable: isAny + || checker.getPropertyOfType(value, PHANTOM) !== undefined + || checker.getIndexInfosOfType(value).length > 0, + dataSource: member ? checker.typeToString(checker.getTypeOfSymbolAtLocation(member, at)) : null, + }; +} + +/** `SchemaRendererContextType | null` as `@object-ui/react` exports it. */ +function declared(program: ts.Program): Reading { + const checker = program.getTypeChecker(); + const sf = program.getSourceFile(CONTEXT_FILE); + const moduleSymbol = sf && checker.getSymbolAtLocation(sf); + const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol) + .find((s) => s.name === 'SchemaRendererContext'); + if (!sf || !exported) throw new Error('setup failure: `SchemaRendererContext` did not resolve to its source export'); + const [valueType] = checker.getTypeArguments(checker.getTypeOfSymbol(exported) as ts.TypeReference); + if (!valueType) throw new Error('setup failure: the context export carries no type argument'); + return describeValue(checker, valueType, sf); +} + +function readSites(program: ts.Program): Record { + const checker = program.getTypeChecker(); + return Object.fromEntries(SITES.map((owner) => { + const call = hookCall(program, owner); + return [owner, describeValue(checker, checker.getTypeAtLocation(call), call)]; + })); +} + +interface Measurement { + declared: Reading; + sites: Record; + planted: Record; +} + +function measure(): Measurement { + const build = programFactory(); + const program = build(); + const sf = program.getSourceFile(SITE_FILE)!; + // Plant the cast after each context argument, last position first so the + // earlier offsets stay valid. + const ends = SITES.map((owner) => hookCall(program, owner).arguments[0].end).sort((a, b) => b - a); + let planted = sf.text; + for (const end of ends) planted = planted.slice(0, end) + PLANTED_CAST + planted.slice(end); + return { + declared: declared(program), + sites: readSites(program), + planted: readSites(build(planted)), + }; +} + +// MODULE SCOPE on purpose (AGENTS.md 测试纪律): the compiler work lands in the +// import phase, which no test or hook timeout bounds. A failure is kept and +// re-thrown by every test that reads the measurement, so it surfaces as named +// failures rather than as a collection error. +let outcome: Measurement | Error; +try { + outcome = measure(); +} catch (error) { + outcome = error instanceof Error ? error : new Error(String(error)); +} +const measurement = (): Measurement => { + if (outcome instanceof Error) throw outcome; + return outcome; +}; + +describe('objectui#7209 — the instrument is calibrated before it is believed', () => { + it('resolved the declared context type from `@object-ui/react`’s own export', () => { + const { declared: d } = measurement(); + expect(d.isAny).toBe(false); + expect(d.phantomReadable).toBe(false); + expect(d.dataSource).toMatch(/\bDataSource\b/); + }); + + for (const owner of SITES) { + it(`\`${owner}\` with the cast planted back IN MEMORY reads \`any\` — the verdicts below can fail`, () => { + expect(measurement().planted[owner]).toMatchObject({ isAny: true, phantomReadable: true, dataSource: null }); + }); + } +}); + +describe('objectui#7209 — each bridge reads SchemaRendererContext as declared', () => { + for (const owner of SITES) { + it(`\`${owner}\`: the hook call carries the declared context type, not \`any\``, () => { + const { declared: d, sites } = measurement(); + expect({ owner, type: sites[owner].type, isAny: sites[owner].isAny }) + .toEqual({ owner, type: d.type, isAny: false }); + }); + + it(`\`${owner}\`: PROBE — the phantom \`${PHANTOM}\` member is not readable off the value`, () => { + expect(measurement().sites[owner].phantomReadable).toBe(false); + }); + + it(`\`${owner}\`: LIT CONTROL — \`dataSource\` reads at its declared type`, () => { + const { declared: d, sites } = measurement(); + expect(sites[owner].dataSource).toBe(d.dataSource); + }); + } +}); diff --git a/packages/react/src/hooks/useElementDataSource.ts b/packages/react/src/hooks/useElementDataSource.ts index dab70a5a82..4a01194c67 100644 --- a/packages/react/src/hooks/useElementDataSource.ts +++ b/packages/react/src/hooks/useElementDataSource.ts @@ -20,7 +20,7 @@ * resolution. One resolver in the runtime layer, not one per plugin. */ -import { useContext, useEffect, useMemo, useState, type Context } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { collectSavedViews, composeElementDataSource, @@ -134,7 +134,10 @@ export function useElementDataSource( schema: unknown, dataSource?: unknown, ): UseElementDataSourceResult { - const context = useContext(SchemaRendererContext as Context); + // Read AS DECLARED (objectui#7209) — no cast between the hook and the value, + // so a member the context does not declare is a compile error here. Pinned by + // `useElementDataSource.schemaRendererContextRead-7209.test.ts`. + const context = useContext(SchemaRendererContext); const adapter = (dataSource ?? context?.dataSource ?? null) as ViewCapableDataSource | null; const config = useMemo(() => {