From cc8c3bd42b7d742eded1788e46c1f517e097b83c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:58:46 +0000 Subject: [PATCH] feat: sourceNames.locations for diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `solid.sourceNames` gains a `locations` kind: a root-relative `file:line:col` beside every component, binding and primitive label the other kinds emit, so a diagnostic report, a whyDidRun chain or a devtools entry points at the declaring line and the dev-server URL `${origin}/${path}` opens it in the browser's sources panel. On under `dev`; off for `observe` and production builds unless set — an observe build is a shipped bundle, so a source path in it is an opt-in. The resolved value goes to both JSX compilers as `sourceNames.locations` and to the primitives pass as `locations`, with `root: config.root` (the resolved Vite root) beside it whenever a location is going to be written. `@solidjs/compiler` throws on an option it does not know, so the plugin probes the loaded compiler once (an empty-module transform per function with the new keys) and leaves both keys out on an older release, warning once only when `locations` was asked for explicitly; the babel plugin ignores unknown keys and always takes them. The posture table, the probe and the option assembly move to src/source-names.ts, covered by a new root vitest suite (`pnpm test:unit`, wired into CI) that also drives the plugin through a Vite dev server against the installed compiler on either side of the compiler release that adds the kind. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY --- .changeset/source-names-locations.md | 5 + .github/workflows/vitest.yml | 3 + README.md | 33 ++- package.json | 6 +- pnpm-lock.yaml | 3 + src/index.ts | 215 +++++++++++------ src/source-names.test.ts | 349 +++++++++++++++++++++++++++ src/source-names.ts | 194 +++++++++++++++ tsconfig.json | 2 +- vitest.config.ts | 10 + 10 files changed, 739 insertions(+), 81 deletions(-) create mode 100644 .changeset/source-names-locations.md create mode 100644 src/source-names.test.ts create mode 100644 src/source-names.ts create mode 100644 vitest.config.ts diff --git a/.changeset/source-names-locations.md b/.changeset/source-names-locations.md new file mode 100644 index 00000000..f8ac641b --- /dev/null +++ b/.changeset/source-names-locations.md @@ -0,0 +1,5 @@ +--- +"@solidjs/vite-plugin": patch +--- + +`sourceNames` gains a `locations` kind: a `file:line:col` next to every component, binding and primitive label — `createComponent(Home, props, "Home", "src/App.tsx:12:5")`, `createSignal(0, { name: "count", loc: "src/store.ts:3:22" })` — so diagnostics, `whyDidRun` chains and devtools point at the line that declared the node. On in dev; off for `observe` and production builds unless set (`{ locations: true }` or `sourceNames: true`), since those are shipped bundles. The plugin passes the resolved Vite root to both JSX compilers and to the primitives pass as `root`, so paths are root-relative and `http://localhost:5173/` opens the file in the browser's sources panel. Requires the `@solidjs/compiler` release that takes the option; the plugin probes the installed compiler and, on an older one, keeps passing the names without locations (warning once when `locations` was asked for explicitly). diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml index 6c93794c..00f33efb 100644 --- a/.github/workflows/vitest.yml +++ b/.github/workflows/vitest.yml @@ -29,6 +29,9 @@ jobs: - name: Install project dependencies run: pnpm i + - name: Run unit tests + run: pnpm run test:unit + - name: Install playwright browser working-directory: examples/${{ matrix.example }} run: pnpm exec playwright install chromium diff --git a/README.md b/README.md index 78a05442..74508fce 100644 --- a/README.md +++ b/README.md @@ -1009,8 +1009,9 @@ here is merged over them and applied to whichever backend is selected. ##### options.solid.sourceNames -- Type: `boolean | { components?: boolean; bindings?: boolean; primitives?: boolean }` -- Default: on in dev and under `observe`, off in production builds +- Type: + `boolean | { components?: boolean; bindings?: boolean; primitives?: boolean; locations?: boolean }` +- Default: on in dev and under `observe`, off in production builds; `locations` on in dev only Which names as written in source are carried into output so the dev and observe runtimes can label the reactive graph after minification — in diagnostics, `whyDidRun` chains, error @@ -1024,6 +1025,13 @@ owner paths, and the Chrome performance tracks. Three kinds: are declared as (`count`, `doubled`, `todos.title`), prefixed with the enclosing non-component function for composed primitives (`createCounter.value`). An explicit `name` option is never overridden. +- `locations` adds a `file:line:col` next to every name the other kinds emit — + `createComponent(Home, props, "Home", "src/App.tsx:12:5")`, + `createSignal(0, { name: "count", loc: "src/store.ts:3:22" })` — so a diagnostic report, a + `whyDidRun` chain, or a devtools entry points at the line that declared the node. Paths are + relative to the Vite root, so under `vite dev` the console's + `http://localhost:5173/src/App.tsx:12:5` opens the file in the browser's sources panel. A + location is only emitted where its name is. The kinds come from two different places. `components` and `bindings` are the JSX compiler's own `sourceNames` option, passed through to whichever backend compiles your JSX @@ -1036,10 +1044,23 @@ too. The default follows the posture: on whenever the plugin compiles for the dev runtime (the same `dev` flag it hands the compilers — `vite dev`, or `dev: true`) and for `observe` builds; off for production builds, whose runtime ignores the names anyway, so production -output is unchanged. `sourceNames: false` turns every kind off, in dev too; `true` turns every -kind on, production builds included. The object form sets the listed kinds and leaves the -rest at the posture default — `{ primitives: false }` keeps component and binding names in -dev but skips the primitives pass. +output is unchanged. `locations` is the one kind that does not follow `observe`: an observe +build is a production bundle, and a source path inside it is an opt-in, so it is on in dev +only. `sourceNames: false` turns every kind off, in dev too; `true` turns every kind on, +production builds included. The object form sets the listed kinds and leaves the rest at the +posture default — `{ primitives: false }` keeps component and binding names in dev but skips +the primitives pass; `{ locations: true }` ships locations in an observe build. + +| `sourceNames` | dev | `observe` build | production build | +| ---------------------- | ---------------------- | ---------------------------- | --------------------------- | +| (unset) | all on | names on, `locations` off | all off | +| `true` | all on | all on | all on | +| `false` | all off | all off | all off | +| `{ kind: true/false }` | as given; the rest on | as given; the rest as unset | as given; the rest off | + +`locations` needs a compiler release that takes the option; on an older `@solidjs/compiler` +the names keep coming without locations, with a one-time warning when it was asked for +explicitly. #### options.typescript diff --git a/package.json b/package.json index ffd280a4..a6a6caac 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "prepublishOnly": "pnpm build", "release": "pnpm build && changeset publish", "check": "package-check", - "test": "node scripts/test-examples.ts" + "test": "node scripts/test-examples.ts", + "test:unit": "vitest run" }, "repository": { "type": "git", @@ -88,7 +89,8 @@ "rollup-plugin-cleaner": "^1.0.0", "solid-js": "^2.0.0-rc.14", "typescript": "^5.2.2", - "vite": "^8.2.1" + "vite": "^8.2.1", + "vitest": "^4.1.11" }, "peerDependencies": { "@solidjs/start-devtools": "^1.0.0-next.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a8d166..134fa9f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: vite: specifier: ^8.2.1 version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(jsdom@26.1.0)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) examples/css-matrix: dependencies: diff --git a/src/index.ts b/src/index.ts index ced30641..7b9ab11b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,16 @@ import { } from './server-functions/index.js'; import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js'; import { startEnv } from './start-env.js'; +import { + detectLocationSupport, + getJsxSourceNamesOptions, + getPrimitiveNamesOptions, + getSourceNames, + requestedLocations, + type LocationTarget, + type ResolvedSourceNames, + type SourceNamesOptions, +} from './source-names.js'; import { cleanModuleId, isTsrxCssModule, @@ -202,44 +212,38 @@ export interface ExtensionOptions { } export type Compiler = 'babel' | 'native'; -/** - * Which source names are carried into output for the dev and observe - * runtimes to label the reactive graph with (see - * `Options.solid.sourceNames`). Each kind defaults to the posture: on for - * dev and `observe`, off for production builds. `components` and `bindings` - * are the JSX compiler's `sourceNames` option; `primitives` is the native - * compiler's standalone `transformSourceNames` pass, which the plugin runs - * on every module — babel apps included. - */ -export interface SourceNamesOptions { - /** `createComponent(Home, props, "Home")` — owners labelled ``. */ - components?: boolean; - /** Binding effects named by what they write: `span.textContent`, `div.children`. */ - bindings?: boolean; - /** - * Primitives named after the identifier they are declared as — - * `createSignal(0, { name: "count" })`, `createCounter.value` inside a - * composed primitive — by the native compiler's `transformSourceNames` - * pass. Runs on `.ts`/`.js` modules as well as components (outside - * node_modules), whichever JSX compiler the app uses. - */ - primitives?: boolean; -} +export type { SourceNamesOptions }; -export type SolidOptions = Omit & { +// `root` (the project root `sourceNames.locations` paths are relative to) +// is not a user option: the plugin passes Vite's resolved `config.root`. +export type SolidOptions = Omit< + JsxCompilerOptions, + 'filename' | 'sourceMap' | 'sourceNames' | 'root' +> & { /** * Source names in output: `true`/`false` for every kind, or per kind. * Defaults to the posture — on for dev and `observe`, off for production - * builds; `false` opts out of every kind, in dev too. + * builds (`locations`: on for dev only); `false` opts out of every kind, + * in dev too. */ sourceNames?: boolean | SourceNamesOptions; }; type NativeCompiler = typeof import('@solidjs/compiler'); let nativeCompilerPromise: Promise | undefined; +/** + * Whether the loaded `@solidjs/compiler` takes `sourceNames.locations` and + * `root` (see `detectLocationSupport`). Probed once, when the compiler + * loads, so it is only meaningful after `loadNativeCompiler()` resolved — + * every caller that builds native options awaits the compiler first. + */ +let nativeLocations = false; async function loadNativeCompiler() { try { - return await (nativeCompilerPromise ??= import('@solidjs/compiler')); + return await (nativeCompilerPromise ??= import('@solidjs/compiler').then((compiler) => { + nativeLocations = detectLocationSupport(compiler); + return compiler; + })); } catch (error) { nativeCompilerPromise = undefined; const reason = error instanceof Error ? `\n\nCause: ${error.message}` : ''; @@ -628,13 +632,24 @@ function getJestDomExport(setupFiles: string[], root: string) { ); } +/** + * The options for one JSX compile. `jsxCompiler` names the backend the + * result goes to, which decides whether `sourceNames.locations` and `root` + * can be included (the native compiler rejects options it does not know, + * so its probe result gates them; the babel plugin ignores unknown keys); + * for `'native'` the compiler must already be loaded (`loadNativeCompiler` + * resolved) for the probe to have run. `root` is the resolved Vite root + * the locations are made relative to. + */ function getSolidOptions( options: Partial, isSsr: boolean, dev: boolean, observe: boolean, - isTestMode = false, -): SolidOptions { + isTestMode: boolean, + jsxCompiler: Compiler, + root: string, +): SolidOptions & { root?: string } { let solidOptions: Pick; if (isTestMode) { @@ -693,50 +708,29 @@ function getSolidOptions( // // The compilers default their own `sourceNames` on under `dev` (rc.10), so // the resolved value is always passed — `false` when both kinds are off — - // and the plugin's table below, not the compiler default, decides. That is - // what makes `solid.sourceNames: false` an opt-out in dev rather than a - // no-op. + // and the plugin's table (getSourceNames), not the compiler default, + // decides. That is what makes `solid.sourceNames: false` an opt-out in dev + // rather than a no-op. `locations` rides inside the same object, with + // `root` (the Vite root the paths are relative to) beside it, on a + // compiler that takes them: see getJsxSourceNamesOptions. const { sourceNames: _userSourceNames, ...userSolidOptions } = options.solid || {}; - const { components, bindings } = getSourceNames(options, dev, observe); + const names = getSourceNames(options.solid?.sourceNames, dev, observe); return { ...solidOptions, ...(serverComponents && solidOptions.generate === 'ssr' ? { serverComponents: true } : {}), dev, - sourceNames: components || bindings ? { components, bindings } : false, + ...getJsxSourceNamesOptions(names, locationTarget(jsxCompiler, root)), ...userSolidOptions, }; } /** - * Resolve `solid.sourceNames` to one flag per kind — `components` and - * `bindings` go to the JSX compiler, `primitives` gates the standalone - * `transformSourceNames` pass in the transform hook. - * - * The default follows the posture. `dev` here is the same flag the compilers - * receive as `dev` (`options.dev`, which defaults to on under `vite dev` and - * off for `vite build`), and `observe` is `options.observe`; those are the - * two runtimes that read the names, and the production runtime ignores them. - * - * | `solid.sourceNames` | dev or observe | production | - * | ----------------------- | ------------------------- | -------------------------- | - * | (unset) | all on | all off | - * | `true` | all on | all on | - * | `false` | all off | all off | - * | `{ kind: true/false }` | as given; the rest on | as given; the rest off | + * What a compiler can do with locations: the babel plugin ignores keys it + * does not know, so it always takes them; the native compiler answers + * through its load-time probe. */ -function getSourceNames( - options: Partial, - dev: boolean, - observe: boolean, -): Required { - const posture = dev || observe; - const user = options.solid?.sourceNames; - if (typeof user === 'boolean') return { components: user, bindings: user, primitives: user }; - return { - components: user?.components ?? posture, - bindings: user?.bindings ?? posture, - primitives: user?.primitives ?? posture, - }; +function locationTarget(compiler: Compiler, root: string): LocationTarget { + return { supportsLocations: compiler === 'babel' || nativeLocations, root }; } /** @@ -757,17 +751,39 @@ const DECLARATION_MODULE = /\.d\.[mc]?ts$/i; const PRIMITIVE_SOURCES = ['solid-js', '@solidjs/signals']; let warnedMissingSourceNamesPass = false; +let warnedMissingLocations = false; + +/** + * Warn once when the user asked for `locations` in so many words and the + * native compiler predates the kind — the dev default stays silent, like + * every other default-on nicety an older compiler cannot provide. Call + * after `loadNativeCompiler()` resolved (the probe sets `nativeLocations`). + */ +function warnMissingLocations(ctx: { warn(message: string): void }, options: Partial) { + if (nativeLocations || warnedMissingLocations) return; + if (!requestedLocations(options.solid?.sourceNames)) return; + warnedMissingLocations = true; + ctx.warn( + '@solidjs/vite-plugin: the installed @solidjs/compiler does not take the `locations` ' + + 'source-names kind, so `solid.sourceNames.locations` is ignored and diagnostics ' + + 'carry no file:line:col. Update @solidjs/compiler.', + ); +} /** * Run the compiler's `transformSourceNames` pass, or leave the code alone * (warning once) on a compiler predating it — the pass is a default-on - * nicety for dev/observe, not something to fail a build over. + * nicety for dev/observe, not something to fail a build over. `names` is + * the resolved `solid.sourceNames` (its `locations` flag goes to the pass) + * and `root` the Vite root locations are made relative to. */ async function transformPrimitiveNames( ctx: { warn(message: string): void }, compiler: NativeCompiler, code: string, filename: string, + names: ResolvedSourceNames, + root: string, ): Promise<{ code: string; map: ChainableMap } | null> { if (!PRIMITIVE_SOURCES.some((source) => code.includes(source))) return null; if (typeof compiler.transformSourceNamesAsync !== 'function') { @@ -781,7 +797,10 @@ async function transformPrimitiveNames( } return null; } - const result = await compiler.transformSourceNamesAsync(code, { filename, sourceMap: true }); + const result = await compiler.transformSourceNamesAsync( + code, + getPrimitiveNamesOptions(names, locationTarget('native', root), filename), + ); // Nothing to name: the pass hands the source back verbatim, with no map. if (result.code === code) return null; return { code: result.code, map: result.map }; @@ -1207,7 +1226,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; const compiler = await loadNativeCompiler(); const result = await compiler.transformAsync(source, { - ...getSolidOptions(options, false, replaceDev, observe, isTestMode), + ...getSolidOptions(options, false, replaceDev, observe, isTestMode, 'native', projectRoot), filename: cleanModuleId(id), sourceMap: false, }); @@ -1345,8 +1364,16 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } async function compileTsrxCss(source: string, id: string): Promise { - const solidOptions = getSolidOptions(options, false, replaceDev, observe, isTestMode); if (options.compiler === 'babel') { + const solidOptions = getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'babel', + projectRoot, + ); const babelUserOptions = await getBabelUserOptions(options, source, id, false); const babelOptions = mergeAndConcat(babelUserOptions, { root: projectRoot, @@ -1367,7 +1394,18 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { return result ? babelTsrxCss(result) : ''; } + // The compiler is loaded ahead of the options: its location probe runs + // at load, and getSolidOptions reads the result. const compiler = await loadNativeCompiler(); + const solidOptions = getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ); const result = await compiler.transformAsync(source, { ...solidOptions, filename: id, @@ -2137,8 +2175,8 @@ export default manifest;`; // (Solid's own flow controls included) name what they mean to name, // and a composed primitive from a library is identified by its // package, not re-labelled by this app's build. - const namePrimitives = - !inNodeModules && getSourceNames(options, replaceDev, observe).primitives; + const sourceNames = getSourceNames(options.solid?.sourceNames, replaceDev, observe); + const namePrimitives = !inNodeModules && sourceNames.primitives; if (!(/\.[mc]?[tj]sx$/i.test(id) || isTsrx || allExtensions.includes(currentFileExtension))) { // Not a JSX module. Primitive naming stays gated as before @@ -2162,7 +2200,15 @@ export default manifest;`; const compiler = await loadNativeCompiler(); if (namePrimitives) { - const named = await transformPrimitiveNames(this, compiler, code, id); + warnMissingLocations(this, options); + const named = await transformPrimitiveNames( + this, + compiler, + code, + id, + sourceNames, + projectRoot, + ); if (named !== null) { code = named.code; maps.push(named.map); @@ -2188,8 +2234,6 @@ export default manifest;`; return { code: finalCode, map: combineSourcemaps(maps) }; } - const solidOptions = getSolidOptions(options, !!isSsr, replaceDev, observe, isTestMode); - // We need to know if the current file extension has a typescript options tied to it const shouldBeProcessedWithTypescript = /\.[mc]?tsx$/i.test(id) || @@ -2237,13 +2281,33 @@ export default manifest;`; // compiler backends. Sourcemaps are collected in application order and // merged at the end. const compiler = await loadNativeCompiler(); + // After the compiler load: the options carry `sourceNames.locations` + // and `root` only when the load-time probe found the native compiler + // takes them (the babel backend always does). + const solidOptions = getSolidOptions( + options, + !!isSsr, + replaceDev, + observe, + isTestMode, + options.compiler === 'babel' ? 'babel' : 'native', + projectRoot, + ); + warnMissingLocations(this, options); let code = source; const maps: ChainableMap[] = []; // Authored TSRX cannot be parsed by a standalone pass; its primitives // are named after Solid lowering, on the generated module, below. if (namePrimitives && !isTsrx) { - const named = await transformPrimitiveNames(this, compiler, code, nativeFilename); + const named = await transformPrimitiveNames( + this, + compiler, + code, + nativeFilename, + sourceNames, + projectRoot, + ); if (named !== null) { code = named.code; maps.push(named.map); @@ -2307,7 +2371,14 @@ export default manifest;`; } if (namePrimitives) { - const named = await transformPrimitiveNames(this, compiler, code, generatedFilename); + const named = await transformPrimitiveNames( + this, + compiler, + code, + generatedFilename, + sourceNames, + projectRoot, + ); if (named !== null) { code = named.code; maps.push(named.map); diff --git a/src/source-names.test.ts b/src/source-names.test.ts new file mode 100644 index 00000000..26a5310f --- /dev/null +++ b/src/source-names.test.ts @@ -0,0 +1,349 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as compiler from '@solidjs/compiler'; +import solidPlugin from './index.js'; +import { + detectLocationSupport, + getJsxSourceNamesOptions, + getPrimitiveNamesOptions, + getSourceNames, + requestedLocations, + type LocationProbeCompiler, +} from './source-names.js'; + +const ROOT = '/app'; +const ALL_ON = { components: true, bindings: true, primitives: true, locations: true }; +const ALL_OFF = { components: false, bindings: false, primitives: false, locations: false }; + +/** + * A compiler that validates like `@solidjs/compiler` 2.0.0-rc.14 — the + * release before the `locations` kind: an unknown key throws. + */ +function strictCompiler(known: { + transform: string[]; + sourceNames: string[]; + transformSourceNames: string[]; +}): LocationProbeCompiler & { calls: unknown[] } { + const reject = (keys: string[], options: Record, what: string) => { + for (const key of Object.keys(options)) { + if (!keys.includes(key)) throw new Error(`received unknown ${what} \`${key}\``); + } + }; + const calls: unknown[] = []; + return { + calls, + transform(_code, options: Record = {}) { + calls.push(options); + reject(known.transform, options, 'option'); + if (typeof options.sourceNames === 'object' && options.sourceNames) { + reject( + known.sourceNames, + options.sourceNames as Record, + '`sourceNames` kind', + ); + } + return { code: '', map: null }; + }, + transformSourceNames(_code, options: Record = {}) { + calls.push(options); + reject(known.transformSourceNames, options, 'option'); + return { code: '', map: null }; + }, + }; +} + +const RC14_KEYS = { + transform: ['filename', 'generate', 'hydratable', 'dev', 'sourceNames', 'sourceMap'], + sourceNames: ['components', 'bindings'], + transformSourceNames: ['filename', 'sourceMap'], +}; +const LOCATIONS_KEYS = { + transform: [...RC14_KEYS.transform, 'root'], + sourceNames: [...RC14_KEYS.sourceNames, 'locations'], + transformSourceNames: [...RC14_KEYS.transformSourceNames, 'locations', 'root'], +}; + +describe('getSourceNames', () => { + it('turns every kind on under dev, locations included', () => { + expect(getSourceNames(undefined, true, false)).toEqual(ALL_ON); + expect(getSourceNames(undefined, true, true)).toEqual(ALL_ON); + }); + + it('keeps locations off under observe alone: a production bundle', () => { + expect(getSourceNames(undefined, false, true)).toEqual({ ...ALL_ON, locations: false }); + }); + + it('turns every kind off for a production build', () => { + expect(getSourceNames(undefined, false, false)).toEqual(ALL_OFF); + }); + + it('`true` turns every kind on whatever the posture, `false` every kind off', () => { + expect(getSourceNames(true, false, false)).toEqual(ALL_ON); + expect(getSourceNames(true, false, true)).toEqual(ALL_ON); + expect(getSourceNames(false, true, false)).toEqual(ALL_OFF); + expect(getSourceNames(false, false, true)).toEqual(ALL_OFF); + }); + + it('the object form sets the listed kinds and leaves the rest at the posture', () => { + expect(getSourceNames({ locations: true }, false, true)).toEqual(ALL_ON); + expect(getSourceNames({ locations: true }, false, false)).toEqual({ + ...ALL_OFF, + locations: true, + }); + expect(getSourceNames({ locations: false }, true, false)).toEqual({ + ...ALL_ON, + locations: false, + }); + expect(getSourceNames({ primitives: false }, true, false)).toEqual({ + ...ALL_ON, + primitives: false, + }); + expect(getSourceNames({ components: false }, false, true)).toEqual({ + components: false, + bindings: true, + primitives: true, + locations: false, + }); + }); +}); + +describe('requestedLocations', () => { + it('is only an explicit ask', () => { + expect(requestedLocations(true)).toBe(true); + expect(requestedLocations({ locations: true })).toBe(true); + expect(requestedLocations(undefined)).toBe(false); + expect(requestedLocations(false)).toBe(false); + expect(requestedLocations({})).toBe(false); + expect(requestedLocations({ locations: false })).toBe(false); + expect(requestedLocations({ components: true })).toBe(false); + }); +}); + +describe('detectLocationSupport', () => { + it('is false for a compiler that rejects the keys', () => { + expect(detectLocationSupport(strictCompiler(RC14_KEYS))).toBe(false); + }); + + it('is false for a compiler without the primitives pass', () => { + const { transform } = strictCompiler(LOCATIONS_KEYS); + expect(detectLocationSupport({ transform })).toBe(false); + }); + + it('is false when only one of the two functions takes the keys', () => { + expect( + detectLocationSupport( + strictCompiler({ ...LOCATIONS_KEYS, transformSourceNames: RC14_KEYS.transformSourceNames }), + ), + ).toBe(false); + expect( + detectLocationSupport( + strictCompiler({ ...LOCATIONS_KEYS, sourceNames: RC14_KEYS.sourceNames }), + ), + ).toBe(false); + }); + + it('is true for a compiler that takes them, and probes with empty modules', () => { + const stub = strictCompiler(LOCATIONS_KEYS); + expect(detectLocationSupport(stub)).toBe(true); + expect(stub.calls).toEqual([ + { filename: '/probe.jsx', root: '/', sourceNames: { locations: true } }, + { filename: '/probe.ts', root: '/', locations: true }, + ]); + }); +}); + +describe('getJsxSourceNamesOptions', () => { + const dev = getSourceNames(undefined, true, false); + + it('passes locations and root to a compiler that takes them', () => { + expect(getJsxSourceNamesOptions(dev, { supportsLocations: true, root: ROOT })).toEqual({ + sourceNames: { components: true, bindings: true, locations: true }, + root: ROOT, + }); + }); + + it('passes the resolved `locations: false` without root', () => { + const observe = getSourceNames(undefined, false, true); + const result = getJsxSourceNamesOptions(observe, { supportsLocations: true, root: ROOT }); + expect(result).toEqual({ sourceNames: { components: true, bindings: true, locations: false } }); + expect('root' in result).toBe(false); + }); + + it('leaves both keys out for a compiler that rejects them', () => { + const result = getJsxSourceNamesOptions(dev, { supportsLocations: false, root: ROOT }); + expect(result).toEqual({ sourceNames: { components: true, bindings: true } }); + expect('locations' in (result.sourceNames as object)).toBe(false); + expect('root' in result).toBe(false); + expect(() => + strictCompiler(RC14_KEYS).transform('', { ...result, dev: true, generate: 'dom' }), + ).not.toThrow(); + }); + + it('resolves to `sourceNames: false` when neither JSX kind is on', () => { + const production = getSourceNames(undefined, false, false); + expect(getJsxSourceNamesOptions(production, { supportsLocations: true, root: ROOT })).toEqual({ + sourceNames: false, + }); + expect( + getJsxSourceNamesOptions( + { ...production, locations: true }, + { supportsLocations: true, root: ROOT }, + ), + ).toEqual({ sourceNames: false }); + }); +}); + +describe('getPrimitiveNamesOptions', () => { + const dev = getSourceNames(undefined, true, false); + + it('passes locations and root to a compiler that takes them', () => { + expect(getPrimitiveNamesOptions(dev, { supportsLocations: true, root: ROOT }, '/a.ts')).toEqual( + { + filename: '/a.ts', + sourceMap: true, + locations: true, + root: ROOT, + }, + ); + const observe = getSourceNames(undefined, false, true); + expect( + getPrimitiveNamesOptions(observe, { supportsLocations: true, root: ROOT }, '/a.ts'), + ).toEqual({ filename: '/a.ts', sourceMap: true, locations: false }); + }); + + it('leaves both keys out for a compiler that rejects them', () => { + const result = getPrimitiveNamesOptions(dev, { supportsLocations: false, root: ROOT }, '/a.ts'); + expect(result).toEqual({ filename: '/a.ts', sourceMap: true }); + expect(() => strictCompiler(RC14_KEYS).transformSourceNames('', result)).not.toThrow(); + }); +}); + +describe('the installed @solidjs/compiler', () => { + // Whether this compiler takes the kind decides what the assertions below + // expect, so the suite is green on either side of the compiler release + // that adds it and proves the plugin degrades (or delivers) accordingly. + const supportsLocations = detectLocationSupport(compiler); + const target = { supportsLocations, root: ROOT }; + const dev = getSourceNames(undefined, true, false); + + it('accepts the assembled JSX options and names the component', async () => { + const result = await compiler.transformAsync('const a = ;', { + generate: 'dom', + hydratable: false, + dev: true, + ...getJsxSourceNamesOptions(dev, target), + filename: `${ROOT}/src/App.jsx`, + sourceMap: false, + }); + expect(result.code).toContain('"Home"'); + if (supportsLocations) expect(result.code).toContain('"src/App.jsx:1:11"'); + else expect(result.code).not.toContain('src/App.jsx:'); + }); + + it('accepts the assembled primitives-pass options and names the signal', async () => { + const result = await compiler.transformSourceNamesAsync( + 'import { createSignal } from "solid-js";\nconst [count] = createSignal(0);\n', + getPrimitiveNamesOptions(dev, target, `${ROOT}/src/store.ts`), + ); + expect(result.code).toContain('name: "count"'); + if (supportsLocations) expect(result.code).toContain('loc: "src/store.ts:2:17"'); + else expect(result.code).not.toContain('loc:'); + }); +}); + +describe('the plugin under vite dev', () => { + const supportsLocations = detectLocationSupport(compiler); + let root: string; + let server: ViteDevServer; + const warnings: string[] = []; + + async function serve(solidOptions: Parameters[0]) { + return createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + customLogger: { + hasWarned: false, + info() {}, + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + error() {}, + clearScreen() {}, + hasErrorLogged: () => false, + }, + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [solidPlugin(solidOptions)], + }); + } + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), 'solid-vite-plugin-locations-')); + // The app imports solid-js / @solidjs/web from this repo's install. + await symlink( + fileURLToPath(new URL('../node_modules', import.meta.url)), + path.join(root, 'node_modules'), + 'dir', + ); + await mkdir(path.join(root, 'src')); + await writeFile( + path.join(root, 'src/App.tsx'), + [ + "import { createSignal } from 'solid-js';", + 'export function Home() {', + ' const [count, setCount] = createSignal(0);', + ' return ;', + '}', + 'export const App = () => ;', + '', + ].join('\n'), + ); + await writeFile(path.join(root, 'src/Other.tsx'), 'export const Other = () =>

other

;\n'); + }); + + afterAll(async () => { + await server?.close(); + await rm(root, { recursive: true, force: true }); + }); + + it('compiles with names, and with locations exactly when the compiler takes them', async () => { + server = await serve({}); + const result = await server.environments.client.transformRequest('/src/App.tsx'); + expect(result?.code).toContain('"Home"'); + expect(result?.code).toContain('name: "count"'); + if (supportsLocations) { + expect(result?.code).toContain('"Home", "src/App.tsx:6:26"'); + expect(result?.code).toContain('loc: "src/App.tsx:3:29"'); + } else { + // (The refresh pass's own cwd-relative `location` metadata is not a + // source-names location.) + expect(result?.code).not.toContain('"Home", "'); + expect(result?.code).not.toContain('loc:'); + } + expect(warnings.filter((message) => message.includes('locations'))).toEqual([]); + await server.close(); + }); + + it('compiles the same module through the babel backend', async () => { + server = await serve({ compiler: 'babel' }); + const result = await server.environments.client.transformRequest('/src/App.tsx'); + expect(result?.code).toContain('"Home"'); + expect(result?.code).toContain('name: "count"'); + expect(warnings.filter((message) => message.includes('locations'))).toEqual([]); + await server.close(); + }); + + it('warns once on an older compiler only when locations were asked for', async () => { + server = await serve({ solid: { sourceNames: { locations: true } } }); + await server.environments.client.transformRequest('/src/App.tsx'); + await server.environments.client.transformRequest('/src/Other.tsx'); + const warned = warnings.filter((message) => message.includes('`locations`')); + expect(warned).toHaveLength(supportsLocations ? 0 : 1); + await server.close(); + }); +}); diff --git a/src/source-names.ts b/src/source-names.ts new file mode 100644 index 00000000..6cd48f59 --- /dev/null +++ b/src/source-names.ts @@ -0,0 +1,194 @@ +/** + * `solid.sourceNames`: which names as written in source the compilers carry + * into output for the dev and observe runtimes, and the source locations + * that ride along with them. This module resolves the user's value to one + * flag per kind and builds the option objects each compiler receives; the + * plugin's transform hooks (src/index.ts) decide when to call it. + */ + +/** + * Which source names are carried into output for the dev and observe + * runtimes to label the reactive graph with (see + * `Options.solid.sourceNames`). Each kind defaults to the posture: on for + * dev and `observe`, off for production builds — except `locations`, which + * defaults on for dev only. `components` and `bindings` are the JSX + * compiler's `sourceNames` option; `primitives` is the native compiler's + * standalone `transformSourceNames` pass, which the plugin runs on every + * module — babel apps included; `locations` qualifies all three. + */ +export interface SourceNamesOptions { + /** `createComponent(Home, props, "Home")` — owners labelled ``. */ + components?: boolean; + /** Binding effects named by what they write: `span.textContent`, `div.children`. */ + bindings?: boolean; + /** + * Primitives named after the identifier they are declared as — + * `createSignal(0, { name: "count" })`, `createCounter.value` inside a + * composed primitive — by the native compiler's `transformSourceNames` + * pass. Runs on `.ts`/`.js` modules as well as components (outside + * node_modules), whichever JSX compiler the app uses. + */ + primitives?: boolean; + /** + * A `file:line:col` source location next to every name the other kinds + * emit — `createComponent(Home, props, "Home", "src/App.tsx:12:5")`, + * `{ name: "count", loc: "src/store.ts:3:22" }` — so a diagnostic + * report, a `whyDidRun` chain, or a devtools entry points at the line + * that declared the node. Paths are relative to the Vite root, so under + * `vite dev` the console link `http://localhost:5173/src/App.tsx:12:5` + * opens the file in the browser's sources panel. A location is only + * emitted where its name is (a kind that is off emits neither). + * + * Defaults on under `dev` and off otherwise: an `observe` build is a + * production bundle, and shipping source paths in it is an opt-in + * (`{ locations: true }`), unlike the names, which `observe` turns on. + * `sourceNames: true` turns it on with every other kind. Requires a + * compiler release that accepts the option; on an older + * `@solidjs/compiler` the names keep coming without locations (a one-time + * warning when it was asked for explicitly). + */ + locations?: boolean; +} + +/** Every kind resolved to a flag. */ +export type ResolvedSourceNames = Required; + +/** + * Resolve `solid.sourceNames` to one flag per kind — `components` and + * `bindings` go to the JSX compiler, `primitives` gates the standalone + * `transformSourceNames` pass in the transform hook, and `locations` + * qualifies all of them. + * + * The default follows the posture. `dev` here is the same flag the compilers + * receive as `dev` (`options.dev`, which defaults to on under `vite dev` and + * off for `vite build`), and `observe` is `options.observe`; those are the + * two runtimes that read the names, and the production runtime ignores them. + * `locations` is the one kind that does not follow `observe`: an observe + * build is a production bundle, and a source path inside it is an opt-in. + * + * | `solid.sourceNames` | dev | observe (no dev) | production | + * | ----------------------- | ----------------------- | ------------------------- | -------------------------- | + * | (unset) | all on | names on, locations off | all off | + * | `true` | all on | all on | all on | + * | `false` | all off | all off | all off | + * | `{ kind: true/false }` | as given; the rest on | as given; the rest as unset | as given; the rest off | + */ +export function getSourceNames( + user: boolean | SourceNamesOptions | undefined, + dev: boolean, + observe: boolean, +): ResolvedSourceNames { + const posture = dev || observe; + if (typeof user === 'boolean') { + return { components: user, bindings: user, primitives: user, locations: user }; + } + return { + components: user?.components ?? posture, + bindings: user?.bindings ?? posture, + primitives: user?.primitives ?? posture, + locations: user?.locations ?? dev, + }; +} + +/** + * Whether the user asked for locations in so many words (`sourceNames: + * true` or `{ locations: true }`), as opposed to getting them from the dev + * default. Decides whether an older compiler's silence is worth a warning. + */ +export function requestedLocations(user: boolean | SourceNamesOptions | undefined): boolean { + return user === true || (typeof user === 'object' && user.locations === true); +} + +/** The slice of `@solidjs/compiler` the location probe exercises. */ +export interface LocationProbeCompiler { + transform(code: string, options?: object | null): unknown; + transformSourceNames?(code: string, options?: object | null): unknown; +} + +/** + * Whether the installed `@solidjs/compiler` takes the `locations` kind and + * the `root` option — on `transform` (`sourceNames.locations`) and on + * `transformSourceNames` (`locations`), which ship together. The compiler + * validates its options in JavaScript ahead of the native call and throws + * `received unknown option` for a key it does not know, so one transform of + * an empty module per function is a cheap, exact probe: it asks the + * compiler that is actually loaded rather than inferring from a version + * string what a prerelease carries. Called once per compiler load. + */ +export function detectLocationSupport(compiler: LocationProbeCompiler): boolean { + if (typeof compiler.transformSourceNames !== 'function') return false; + try { + compiler.transform('', { filename: '/probe.jsx', root: '/', sourceNames: { locations: true } }); + compiler.transformSourceNames('', { filename: '/probe.ts', root: '/', locations: true }); + return true; + } catch { + return false; + } +} + +/** What the compiler about to receive the options can do with locations. */ +export interface LocationTarget { + /** + * The compiler accepts `locations` and `root`. The native compiler is + * probed (`detectLocationSupport`); `@solidjs/babel-plugin` ignores keys + * it does not know, so it always takes them. + */ + supportsLocations: boolean; + /** The resolved Vite root locations are made relative to. */ + root: string; +} + +/** The `sourceNames` value a JSX compiler receives, plus `root` when locations ride along. */ +export interface JsxSourceNamesOptions { + sourceNames: false | { components: boolean; bindings: boolean; locations?: boolean }; + root?: string; +} + +/** + * The JSX compiler's share of the resolved names: `sourceNames` as the + * compilers define it (`false` when neither JSX kind is on — the compilers + * default their own `sourceNames` on under `dev`, so the resolved value is + * always passed and the plugin's table, not the compiler default, decides) + * and, on a compiler that takes it, `locations` resolved the same way, with + * `root` whenever a location is going to be written. On an older compiler + * neither key is present: `@solidjs/compiler` throws on an unknown option. + */ +export function getJsxSourceNamesOptions( + names: ResolvedSourceNames, + target: LocationTarget, +): JsxSourceNamesOptions { + const { components, bindings, locations } = names; + if (!components && !bindings) return { sourceNames: false }; + if (!target.supportsLocations) return { sourceNames: { components, bindings } }; + return { + sourceNames: { components, bindings, locations }, + ...(locations ? { root: target.root } : {}), + }; +} + +/** Options for `transformSourceNames`, the primitives pass. */ +export interface PrimitiveNamesOptions { + filename: string; + sourceMap: true; + locations?: boolean; + root?: string; +} + +/** + * The primitives pass's options: `locations` (whose compiler default is on, + * so the resolved flag is always passed) and `root` when a location is + * going to be written — both only on a compiler that takes them. + */ +export function getPrimitiveNamesOptions( + names: ResolvedSourceNames, + target: LocationTarget, + filename: string, +): PrimitiveNamesOptions { + if (!target.supportsLocations) return { filename, sourceMap: true }; + return { + filename, + sourceMap: true, + locations: names.locations, + ...(names.locations ? { root: target.root } : {}), + }; +} diff --git a/tsconfig.json b/tsconfig.json index 98599632..839098fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "src", "cypress" ], "exclude": [ - "**/*.spec.ts" + "**/*.spec.ts", "**/*.test.ts" ], "compilerOptions": { "target": "ESNext", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..2f1a903a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// The plugin's own unit tests (`pnpm test:unit`). The example apps under +// examples/* carry their own vitest configs and run through `pnpm test`. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});