diff --git a/.changeset/ssr-codegen-without-ssr-option.md b/.changeset/ssr-codegen-without-ssr-option.md new file mode 100644 index 00000000..ea7960a9 --- /dev/null +++ b/.changeset/ssr-codegen-without-ssr-option.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-solid': patch +--- + +Without `ssr: true`, modules loaded on the server now compile to non-hydratable SSR output instead of DOM output. Prerendering a client-rendered app with `ssrLoadModule` no longer throws "Client-only API called on the server side" (#64). Vitest keeps DOM output. diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml index fc062d92..aad075ac 100644 --- a/.github/workflows/vitest.yml +++ b/.github/workflows/vitest.yml @@ -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 diff --git a/README.md b/README.md index 6b219302..d34a7df8 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ If set to false, it won't inject the runtime in dev. This will force SSR code in the produced files. +With `ssr: true`, client code is hydratable and server code renders with hydration keys. + +With `ssr: false`, client code is non-hydratable DOM output. Modules that Vite loads on the server, for example through `ssrLoadModule` to prerender static pages, get non-hydratable SSR output. They render to HTML without hydration keys. Vitest is the exception and keeps DOM output. + #### options.babel - Type: Babel.TransformOptions diff --git a/package.json b/package.json index a2662f89..cba7d046 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed12c18..23823a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: vite: specifier: ^6.0.0 version: 6.0.9(@types/node@18.19.66)(lightningcss@1.32.0) + vitest: + specifier: ^3.0.7 + version: 3.0.7(@types/node@18.19.66)(@vitest/browser@3.0.7)(jsdom@26.0.0(supports-color@8.1.1))(lightningcss@1.32.0)(msw@2.7.3(@types/node@18.19.66)(typescript@5.7.2))(supports-color@8.1.1) examples/vite-3: dependencies: @@ -5968,6 +5971,28 @@ snapshots: '@types/node': 18.19.66 optional: true + '@vitest/browser@3.0.7(@types/node@18.19.66)(playwright@1.50.1)(typescript@5.7.2)(vite@6.0.9(@types/node@18.19.66)(lightningcss@1.32.0))(vitest@3.0.7)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@vitest/mocker': 3.0.7(msw@2.7.3(@types/node@18.19.66)(typescript@5.7.2))(vite@6.0.9(@types/node@18.19.66)(lightningcss@1.32.0)) + '@vitest/utils': 3.0.7 + magic-string: 0.30.17 + msw: 2.7.3(@types/node@18.19.66)(typescript@5.7.2) + sirv: 3.0.1 + tinyrainbow: 2.0.0 + vitest: 3.0.7(@types/node@18.19.66)(@vitest/browser@3.0.7)(jsdom@26.0.0(supports-color@8.1.1))(lightningcss@1.32.0)(msw@2.7.3(@types/node@18.19.66)(typescript@5.7.2))(supports-color@8.1.1) + ws: 8.18.1 + optionalDependencies: + playwright: 1.50.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - typescript + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.0.7(@types/node@18.19.66)(playwright@1.50.1)(typescript@5.7.2)(vite@6.2.0(@types/node@18.19.66)(lightningcss@1.32.0))(vitest@3.0.7)': dependencies: '@testing-library/dom': 10.4.0 @@ -6038,6 +6063,16 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/mocker@3.0.7(msw@2.7.3(@types/node@18.19.66)(typescript@5.7.2))(vite@6.0.9(@types/node@18.19.66)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.0.7 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + msw: 2.7.3(@types/node@18.19.66)(typescript@5.7.2) + vite: 6.0.9(@types/node@18.19.66)(lightningcss@1.32.0) + optional: true + '@vitest/mocker@3.0.7(msw@2.7.3(@types/node@18.19.66)(typescript@5.7.2))(vite@6.2.0(@types/node@18.19.66)(lightningcss@1.32.0))': dependencies: '@vitest/spy': 3.0.7 @@ -8040,7 +8075,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 18.19.66 - '@vitest/browser': 3.0.7(@types/node@18.19.66)(playwright@1.50.1)(typescript@5.7.2)(vite@6.2.0(@types/node@18.19.66)(lightningcss@1.32.0))(vitest@3.0.7) + '@vitest/browser': 3.0.7(@types/node@18.19.66)(playwright@1.50.1)(typescript@5.7.2)(vite@6.0.9(@types/node@18.19.66)(lightningcss@1.32.0))(vitest@3.0.7) jsdom: 26.0.0(supports-color@8.1.1) transitivePeerDependencies: - jiti diff --git a/src/index.ts b/src/index.ts index 82770246..d17928c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,11 @@ export interface Options { /** * This will force SSR code in the produced files. * + * With `true`, client code is hydratable and server code is hydratable SSR + * output. With `false`, client code is non-hydratable DOM output, and modules + * loaded on the server (for example through `ssrLoadModule` to prerender a + * page) get non-hydratable SSR output. + * * @default false */ ssr?: boolean; @@ -386,7 +391,15 @@ export default function solidPlugin(options: Partial = {}): Plugin { solidOptions = { generate: 'dom', hydratable: true }; } } else { - solidOptions = { generate: 'dom', hydratable: false }; + // A client-rendered app can still load modules on the server, for + // example to prerender static pages with `ssrLoadModule`. DOM output + // cannot run there, so server transforms use the SSR generate. Nothing + // hydrates the result, so it stays non-hydratable. Vitest keeps DOM + // output, since tests resolve the browser build of solid-js. + solidOptions = { + generate: isSsr && !isTestMode ? 'ssr' : 'dom', + hydratable: false, + }; } // We need to know if the current file extension has a typescript options tied to it diff --git a/src/ssr-codegen.test.ts b/src/ssr-codegen.test.ts new file mode 100644 index 00000000..c3e044bc --- /dev/null +++ b/src/ssr-codegen.test.ts @@ -0,0 +1,85 @@ +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 InlineConfig } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import solidPlugin, { type Options } from './index.js'; + +// https://github.com/solidjs/solid-vite-plugin/issues/64 +describe('JSX codegen per transform', () => { + let root: string; + + async function transform(options: Partial, config: InlineConfig = {}) { + const server = await createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [solidPlugin(options)], + ...config, + }); + try { + const client = await server.environments.client.transformRequest('/src/Page.tsx'); + const ssr = await server.environments.ssr.transformRequest('/src/Page.tsx'); + return { server, client: client!.code, ssr: ssr!.code }; + } catch (error) { + await server.close(); + throw error; + } + } + + async function render(server: Awaited>['server']) { + try { + const { renderToString } = await server.ssrLoadModule('solid-js/web'); + const { default: Page } = await server.ssrLoadModule('/src/Page.tsx'); + return renderToString(() => Page({ name: 'x' })) as string; + } finally { + await server.close(); + } + } + + beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), 'vite-plugin-solid-ssr-codegen-'))); + 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: 'ssr-codegen-fixture', private: true, type: 'module' }), + ); + await mkdir(path.join(root, 'src')); + await writeFile( + path.join(root, 'src/Page.tsx'), + 'export default (props: { name: string }) =>
Hello {props.name}
;\n', + ); + }); + + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('compiles server transforms with the SSR generate when `ssr` is off', async () => { + const { server, client, ssr } = await transform({ hot: false }); + expect(client).toContain('template'); + expect(ssr).not.toContain('template'); + expect(await render(server)).toBe('
Hello x
'); + }); + + it('keeps hydration keys in server output when `ssr` is on', async () => { + const { server, client, ssr } = await transform({ hot: false, ssr: true }); + expect(client).toContain('template'); + expect(ssr).not.toContain('template'); + expect(await render(server)).toMatch(/data-hk=/); + }); + + it('keeps DOM output for server transforms in test mode', async () => { + const { server, ssr } = await transform({ hot: false }, { mode: 'test' }); + await server.close(); + expect(ssr).toContain('template'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 70191cbf..9c12304d 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', + }, +});