diff --git a/.changeset/7837-vscode-export-react-side-effect-import.md b/.changeset/7837-vscode-export-react-side-effect-import.md new file mode 100644 index 000000000..a5e719c67 --- /dev/null +++ b/.changeset/7837-vscode-export-react-side-effect-import.md @@ -0,0 +1,25 @@ +--- +'object-ui': patch +--- + +Fix the phantom import the VS Code extension's **Export to React** command wrote into +every file it generated (objectui#7837). + +`generateReactComponent()` emitted a preamble that imported `registerDefaultRenderers` +from `@object-ui/components` and then called it. That symbol is on **no export** of +that package: its built `dist/index.d.ts` carries exactly one `register*` name, +`registerPlaceholders`, and `registerDefaultRenderers` appears **0 times** in either +`dist/index.d.ts` or `dist/index.js`. So every file the command produced failed to +compile with `TS2305` naming a symbol the user never typed. + +`@object-ui/components` registers its renderers as an **import side effect** — +`sideEffects: true` in its manifest, `import './renderers'` in its barrel under the +comment `Register all ObjectUI renderers (side-effects)`, and **114 `register(` call +sites** at module scope in the built `dist/index.js`. There is no registration function +to call, so the generated preamble now imports the package for the side effect and says +why. Same spelling the root README landed for objectui#7417. + +`packages/vscode-extension/DESIGN.md`, which documented the identical two lines, is +corrected in the same commit so the design record does not freeze the defect. + +No public surface moved: no export added, no signature changed. diff --git a/packages/vscode-extension/DESIGN.md b/packages/vscode-extension/DESIGN.md index 7dbb9ae54..8aeb87a29 100644 --- a/packages/vscode-extension/DESIGN.md +++ b/packages/vscode-extension/DESIGN.md @@ -269,9 +269,9 @@ if (currentType === 'input') { ```typescript import React from 'react'; import { SchemaRenderer } from '@object-ui/react'; -import { registerDefaultRenderers } from '@object-ui/components'; - -registerDefaultRenderers(); +// Importing the package registers every default renderer as a side effect — +// there is no separate registration call. +import '@object-ui/components'; const schema = { /* 用户的schema */ }; diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 4a7f91ae4..728f2947b 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -192,7 +192,7 @@ "scripts": { "vscode:prepublish": "pnpm run build", "build": "tsup", - "type-check": "tsc --noEmit", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint .", "dev": "tsup --watch", "test": "vitest run --passWithNoTests --root ../.. packages/vscode-extension/", diff --git a/packages/vscode-extension/src/__tests__/export-to-react-preamble.test.ts b/packages/vscode-extension/src/__tests__/export-to-react-preamble.test.ts new file mode 100644 index 000000000..b45c4be29 --- /dev/null +++ b/packages/vscode-extension/src/__tests__/export-to-react-preamble.test.ts @@ -0,0 +1,102 @@ +/** + * 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#7837 — pins on the preamble the `Export to React` command writes + * into every file a user gets. + * + * WHY A PIN AT ALL. The preamble lives inside a TEMPLATE LITERAL, so nothing + * in this package's toolchain reads it: `tsc --noEmit` sees a string, `tsup` + * copies it through, and the doc gates' surfaces are `content/docs`, the + * per-app docs trees, `packages/NAME/README.md` and the root `README.md` — + * none of which is this file. For as long as the preamble imported + * `registerDefaultRenderers` from `@object-ui/components` and called it, this + * package's own type-check was green and every generated file failed to + * compile with `TS2305` on a symbol the user never typed. There was no binding + * between the verifier and the thing verified; these assertions are it. + * + * WHY SOURCE TEXT AND NOT A COMPILE. Compiling the emitted code under test + * would mean exporting `generateReactComponent()`, and objectui#7837 is + * explicitly not allowed to move this package's export surface. So these read + * the template out of the source instead. That is weaker than compiling the + * output — it cannot catch a NEW phantom, only the return of this one and the + * loss of its replacement — and the stronger instrument is filed separately. + * + * WHY THE SIDE-EFFECT IMPORT IS THE CORRECT SPELLING (measured, not recalled). + * `@object-ui/components` declares `sideEffects: true`, its barrel runs + * `import './renderers'` under the comment `Register all ObjectUI renderers + * (side-effects)`, and its built `dist/index.js` carries 114 module-scope + * `register(` call sites. Its built `dist/index.d.ts` exports exactly one + * `register*` name — `registerPlaceholders`. There is no registration function + * to call. Same spelling the root README landed for objectui#7417. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const EXTENSION_SRC = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../extension.ts'), + 'utf8' +); + +/** + * The body of the template literal `generateReactComponent()` returns — i.e. + * the text of the file the user receives, with the schema still uninterpolated. + * + * Every failure mode throws with "this pin needs rewriting, not deleting": if + * the generator is refactored, the pin has to be pointed at the new shape, and + * a silently-skipped assertion is exactly the failure this file exists to stop. + */ +function generatedFileTemplate(): string { + const fn = EXTENSION_SRC.indexOf('function generateReactComponent'); + if (fn < 0) { + throw new Error( + 'generateReactComponent() is gone from extension.ts — this pin needs rewriting, not deleting.' + ); + } + const open = EXTENSION_SRC.indexOf('return `', fn); + if (open < 0) { + throw new Error( + 'generateReactComponent() no longer returns a template literal — this pin needs rewriting, not deleting.' + ); + } + const start = open + 'return `'.length; + const close = EXTENSION_SRC.indexOf('`;', start); + if (close < 0) { + throw new Error( + 'unterminated template literal in generateReactComponent() — this pin needs rewriting, not deleting.' + ); + } + return EXTENSION_SRC.slice(start, close); +} + +describe('Export to React — the generated file preamble (objectui#7837)', () => { + it('names no `registerDefaultRenderers`, which @object-ui/components does not export', () => { + expect(generatedFileTemplate()).not.toContain('registerDefaultRenderers'); + // Belt and braces: the identifier is absent from the whole module, so a + // second copy cannot reappear in a helper the template interpolates. + expect(EXTENSION_SRC).not.toContain('registerDefaultRenderers'); + }); + + it('imports @object-ui/components for its side effect, with no named binding', () => { + const template = generatedFileTemplate(); + expect(template).toContain("import '@object-ui/components';"); + // A named import from that package is how the defect was spelled. Assert + // the SHAPE is gone, not just the one identifier, so the next phantom off + // that specifier fails here too. + expect(template).not.toMatch(/import\s*\{[^}]*\}\s*from\s*'@object-ui\/components'/); + }); + + it('still imports SchemaRenderer from @object-ui/react — the renderer it calls', () => { + const template = generatedFileTemplate(); + expect(template).toContain("import { SchemaRenderer } from '@object-ui/react';"); + expect(template).toContain(''); + }); +}); diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index e2060f8e1..e52b0abc1 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -227,10 +227,9 @@ function generateReactComponent(schema: any): string { return `import React from 'react'; import { SchemaRenderer } from '@object-ui/react'; -import { registerDefaultRenderers } from '@object-ui/components'; - -// Register default components once -registerDefaultRenderers(); +// Importing the package registers every default renderer as a side effect — +// there is no separate registration call. +import '@object-ui/components'; const schema = ${schemaJson}; diff --git a/packages/vscode-extension/tsconfig.test.json b/packages/vscode-extension/tsconfig.test.json new file mode 100644 index 000000000..79cdb3c02 --- /dev/null +++ b/packages/vscode-extension/tsconfig.test.json @@ -0,0 +1,35 @@ +{ + // Type-checks this package's TESTS, which `tsconfig.json` excludes. + // + // Why the exclusion exists and why it was a hole: `tsconfig.json` is the + // package BUILD (`rootDir: ./src`, `declaration`, `outDir: ./dist`), so a + // test file left in that program would emit into `dist` and ship inside the + // `.vsix`. Excluding it keeps the build honest — but until this project + // existed, no `tsc` invocation read the test at all, and an unchecked test + // can assert a contract the compiler never checked and then read as evidence + // that the contract holds. Same shape and same reason as the 35 sibling + // `tsconfig.test.json` files; `scripts/check-type-check-coverage.mjs` + // enforces the chaining off `type-check`, since a config nothing runs is the + // objectui#3009 failure itself. + "extends": "./tsconfig.json", + "compilerOptions": { + // A checking project, never an emitting one. + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + // The BUILD is CommonJS (`module: node16`, no `"type": "module"`), which is + // what tsup emits for VS Code. Vitest is not that program: it loads these + // files as ESM, so `import.meta.url` — the only stable way for a test to + // locate its sibling source on disk — is legal there and TS1470 under the + // build's CommonJS target. This project therefore describes the runtime the + // tests actually run in, and the build config keeps describing the .vsix. + "module": "ESNext", + "moduleResolution": "Bundler" + }, + // Both fields are overridden, not merged: `tsconfig.json`'s `exclude` lists + // `**/*.test.ts`, so inheriting it would leave this project with no inputs — + // green, and reading nothing. + "include": ["src/**/*.test.ts"], + "exclude": ["node_modules", "dist"] +}