From e01a6b3a44e6a86d491d335fa73bbbdfea4c77b7 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 19 Oct 2026 11:32:15 +0800 Subject: [PATCH] fix: compile ids whose extension follows the query - The transform stripped the query before checking the extension. Ids like unplugin-icons' `~icons/mdi/folder?width=2em.tsx` then had no extension, so Vite compiled their JSX for React. - When the query-free path is not a JSX file but the full id is, compile the full id. - Read custom extensions from the query-free path. A dot in the query (`a.mdx?x=1.5`) was read as the extension and the module was skipped. - Add dev transform tests for both cases. Fixes #206 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/query-before-extension.md | 5 ++ src/index.ts | 22 +++++++-- src/transform-ids.test.ts | 68 ++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 .changeset/query-before-extension.md create mode 100644 src/transform-ids.test.ts diff --git a/.changeset/query-before-extension.md b/.changeset/query-before-extension.md new file mode 100644 index 0000000..af224c5 --- /dev/null +++ b/.changeset/query-before-extension.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Compile modules whose id has the extension after the query, such as unplugin-icons' `~icons/mdi/folder?width=2em.tsx`. These were left to Vite's default JSX transform, which produced React code (#206). Custom `extensions` are now also matched when the query contains a dot. diff --git a/src/index.ts b/src/index.ts index 7b9ab11..bdac9c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -530,7 +530,18 @@ export interface RefreshOptions { function getExtension(filename: string): string { const index = filename.lastIndexOf('.'); - return index < 0 ? '' : filename.substring(index).replace(/\?.+$/, ''); + return index < 0 ? '' : filename.substring(index); +} + +/** + * The path the transform compiles for `id`. This is normally `id` without its + * query. Some plugins put the extension after the query instead, such as + * unplugin-icons' `~icons/mdi/folder?width=2em.tsx`. Then the full id is used, + * as it was before queries were stripped. #206 + */ +function getTransformPath(id: string, isHandled: (path: string) => boolean): string { + const cleanId = id.replace(/\?.*$/, ''); + return cleanId !== id && !isHandled(cleanId) && isHandled(id) ? id : cleanId; } // The packages whose dev/production server builds are selected by the // `development` export condition. A dependency on either means the package @@ -2152,7 +2163,6 @@ export default manifest;`; async transform(source, id, transformOptions) { if (isTsrxCssModule(id)) return null; const isSsr = getEnvironmentConsumer(this.environment, transformOptions) === 'server'; - const currentFileExtension = getExtension(id); const extensionsToWatch = options.extensions || []; const allExtensions = extensionsToWatch.map((extension) => @@ -2168,7 +2178,13 @@ export default manifest;`; // manifest key / dev URL); keep it for the `$$moduleUrl` injection // while the transform pipeline below works on the clean file path. const moduleId = id; - id = id.replace(/\?.*$/, ''); + id = getTransformPath( + id, + (path) => /\.[mc]?[tj]sx$/i.test(path) || allExtensions.includes(getExtension(path)), + ); + // Read the extension from the query-free path, so a dot inside the + // query (`a.mdx?x=1.5`) is not taken as the extension. + const currentFileExtension = getExtension(id); const isTsrx = isTsrxModule(id); const inNodeModules = /node_modules/.test(id); // Primitive names stop at node_modules: a dependency's internals diff --git a/src/transform-ids.test.ts b/src/transform-ids.test.ts new file mode 100644 index 0000000..2de436d --- /dev/null +++ b/src/transform-ids.test.ts @@ -0,0 +1,68 @@ +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 Plugin, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import solidPlugin from './index.js'; + +let root: string; +let server: ViteDevServer; + +// Resolves ids the way unplugin-icons does: the extension is appended after +// the query, giving `~icons/mdi/folder?width=2em.tsx`. +const iconsPlugin: Plugin = { + name: 'test-icons', + enforce: 'pre', + resolveId(id) { + if (!id.startsWith('~icons/')) return null; + return id.endsWith('.tsx') ? id : `${id}.tsx`; + }, + load(id) { + if (id.startsWith('~icons/')) { + return 'export default (props = {}) => ;'; + } + }, +}; + +beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), 'solid-transform-ids-')); + // The modules import @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/Comp.custom'), 'export const Comp = () =>
custom
;\n'); + server = await createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [solidPlugin({ extensions: ['.custom'] }), iconsPlugin], + }); +}); + +afterAll(async () => { + await server?.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe('transform ids with queries', () => { + // #206: since queries were stripped first, this id lost its extension and + // Vite compiled the JSX for React instead. + it('compiles JSX when the extension follows the query', async () => { + const result = await server.environments.client.transformRequest('~icons/mdi/folder?width=2em'); + expect(result?.code).toContain('_$template('); + expect(result?.code).not.toMatch(/react/i); + }); + + it('reads a custom extension from the path, not from a dot in the query', async () => { + const result = await server.environments.client.transformRequest('/src/Comp.custom?x=1.5'); + expect(result?.code).toContain('_$template('); + expect(result?.code).not.toContain('
custom
'); + }); +});