Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/query-before-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'vite-plugin-solid': patch
---

Compile modules whose id has the extension after the query, such as unplugin-icons' `~icons/mdi/folder?width=2em.tsx`. Since 2.11.2 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.
3 changes: 3 additions & 0 deletions .github/workflows/vitest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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/vite-6
run: pnpm exec playwright install chromium
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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",
Expand Down Expand Up @@ -68,7 +69,8 @@
"rollup-plugin-cleaner": "^1.0.0",
"solid-js": "^1.9.3",
"typescript": "^5.2.2",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^3.0.7"
},
"peerDependencies": {
"@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0",
Expand Down
37 changes: 36 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,18 @@ export interface Options {

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;
}
function containsSolidField(fields: Record<string, any>) {
const keys = Object.keys(fields);
Expand Down Expand Up @@ -357,7 +368,6 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin {
const isSsr = this.environment
? this.environment.config.consumer === 'server'
: Boolean(transformOptions?.ssr);
const currentFileExtension = getExtension(id);

const extensionsToWatch = options.extensions || [];
const allExtensions = extensionsToWatch.map((extension) =>
Expand All @@ -369,7 +379,13 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin {
return null;
}

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);

if (!(/\.[mc]?[tj]sx$/i.test(id) || allExtensions.includes(currentFileExtension))) {
return null;
Expand Down
69 changes: 69 additions & 0 deletions src/transform-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { mkdtemp, mkdir, realpath, 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 = {}) => <svg viewBox="0 0 24 24" {...props} />;';
}
},
};

beforeAll(async () => {
root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-transform-ids-')));
// The modules import solid-js/web from this repo's install.
await symlink(
fileURLToPath(new URL('../node_modules', import.meta.url)),
path.join(root, 'node_modules'),
'dir',
);
await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }));
await mkdir(path.join(root, 'src'));
await writeFile(path.join(root, 'src/Comp.custom'), 'export const Comp = () => <div>custom</div>;\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('<div>custom</div>');
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"src", "cypress"
],
"exclude": [
"**/*.spec.ts"
"**/*.spec.ts", "**/*.test.ts"
],
"compilerOptions": {
"target": "ESNext",
Expand Down
10 changes: 10 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
Loading