From 6179bd660a3ffe15574f130361d24d5b85988531 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 13 Oct 2026 11:07:10 +0800 Subject: [PATCH] fix(config): keep $ServerOnly templates in the client dev build The Solid compiler leaves the templates of $ServerOnly elements out of the client bundle, since hydration reuses the server-rendered DOM. Hot module replacement re-runs the component on the client and needs them, so the first edit to a component whose root uses $ServerOnly threw "template is not a function". - Add solidPlugins, which passes omitServerOnlyTemplates: false to vite-plugin-solid in dev, under the same condition vite-plugin-solid uses to enable hot reloading. Production builds still leave the templates out, and a value set by the user is kept. - Update the lockfile to babel-preset-solid 1.9.15, which brings babel-plugin-jsx-dom-expressions 0.40.11. The option was added in 0.40.10. 1.9.15 still accepts solid-js 1.9.15. Fixes #1930. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-only-hmr.md | 5 +++ packages/start/src/config/index.ts | 5 ++- packages/start/src/config/solid.spec.ts | 54 +++++++++++++++++++++++++ packages/start/src/config/solid.ts | 42 +++++++++++++++++++ pnpm-lock.yaml | 18 ++++----- 5 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 .changeset/server-only-hmr.md create mode 100644 packages/start/src/config/solid.spec.ts create mode 100644 packages/start/src/config/solid.ts diff --git a/.changeset/server-only-hmr.md b/.changeset/server-only-hmr.md new file mode 100644 index 000000000..4c9d25ffe --- /dev/null +++ b/.changeset/server-only-hmr.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Fix hot reloading for components whose root element uses `$ServerOnly`. The first edit threw `template is not a function`, because the Solid compiler leaves `$ServerOnly` templates out of the client bundle and hot reloading needs them to re-run the component. In dev, the client build now keeps these templates. Production builds still leave them out. This needs `babel-plugin-jsx-dom-expressions` 0.40.10 or newer, which fresh installs already get; with an older version the behavior is unchanged. diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 551304a90..9b0edf8c9 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -2,7 +2,7 @@ import { defu } from "defu"; import { globSync } from "node:fs"; import { basename, extname, isAbsolute, join } from "node:path"; import type { PluginOption, FilterPattern } from "vite"; -import solid, { type Options as SolidOptions } from "vite-plugin-solid"; +import type { Options as SolidOptions } from "vite-plugin-solid"; import { type ServerFunctionsOptions, serverFunctionsPlugin } from "../directives/index.ts"; import { appRootAlias } from "./app-root-alias.ts"; import { boundaryModules } from "./boundary-modules.ts"; @@ -15,6 +15,7 @@ import type { BaseFileSystemRouter } from "./fs-routes/router.ts"; import { sanitizeChunkFileName, toPickId } from "./fs-routes/tree-shake.ts"; import lazy from "./lazy.ts"; import { manifest } from "./manifest.ts"; +import { solidPlugins } from "./solid.ts"; import { parseIdQuery } from "./utils.ts"; /** @@ -434,7 +435,7 @@ export function solidStart(options?: SolidStartOptions): Array { }, }, devServer(handlers.server), - solid({ + ...solidPlugins({ ...start.solid, ssr: true, extensions: extensions.map(ext => `.${ext}`), diff --git a/packages/start/src/config/solid.spec.ts b/packages/start/src/config/solid.spec.ts new file mode 100644 index 000000000..2ef4d7184 --- /dev/null +++ b/packages/start/src/config/solid.spec.ts @@ -0,0 +1,54 @@ +import type { Plugin } from "vite"; +import { describe, expect, it } from "vitest"; +import { solidPlugins } from "./solid.ts"; + +const SOURCE = `export default function Page() { + return ( +
+

server only content

+
+ ); +}`; + +/** Compiles SOURCE for the client, as `vite serve` or `vite build` would. */ +async function compileForClient( + command: "serve" | "build", + options: Parameters[0] = {}, +): Promise { + const plugins = solidPlugins({ ssr: true, ...options }) as Plugin[]; + const config = { command, mode: command === "serve" ? "development" : "production" }; + for (const plugin of plugins) { + await (plugin.configResolved as any)?.call({}, config); + } + const solid = plugins.find(plugin => plugin.name === "solid")!; + const result = await (solid.transform as any).call( + {}, + SOURCE, + `${process.cwd()}/src/routes/page.tsx`, + { ssr: false }, + ); + return result.code; +} + +describe("solidPlugins", () => { + // Hot reloading re-runs the component on the client, which needs the + // template (https://github.com/solidjs/solid-start/issues/1930). + it("keeps $ServerOnly templates in the client dev build", async () => { + expect(await compileForClient("serve")).toContain("server only content"); + }); + + it("leaves $ServerOnly templates out of the client production build", async () => { + expect(await compileForClient("build")).not.toContain("server only content"); + }); + + it("keeps the value the user sets", async () => { + const code = await compileForClient("serve", { + solid: { omitServerOnlyTemplates: true } as any, + }); + expect(code).not.toContain("server only content"); + }); + + it("leaves the templates out when hot reloading is turned off", async () => { + expect(await compileForClient("serve", { hot: false })).not.toContain("server only content"); + }); +}); diff --git a/packages/start/src/config/solid.ts b/packages/start/src/config/solid.ts new file mode 100644 index 000000000..ce5c46d47 --- /dev/null +++ b/packages/start/src/config/solid.ts @@ -0,0 +1,42 @@ +import type { Plugin, PluginOption } from "vite"; +import solid, { type Options as SolidOptions } from "vite-plugin-solid"; + +type CompilerOptions = NonNullable & { + /** + * Leave the templates of `$ServerOnly` elements out of client bundles. + * Added in `babel-plugin-jsx-dom-expressions` 0.40.10, and on by default. + */ + omitServerOnlyTemplates?: boolean; +}; + +/** + * `vite-plugin-solid`, set up so that hot reloading works with `$ServerOnly`. + * + * The Solid compiler leaves the templates of `$ServerOnly` elements out of the + * client bundle, since hydration reuses the server-rendered DOM. Hot module + * replacement re-runs the component on the client and needs them, so without + * them the first edit throws "template is not a function" + * (https://github.com/solidjs/solid-start/issues/1930). In dev, keep them. + * Production builds still leave them out, and a value set by the user is kept. + */ +export function solidPlugins(options: Partial): PluginOption[] { + const compilerOptions: CompilerOptions = { ...options.solid }; + + const serverOnlyHmr: Plugin = { + name: "solid-start:server-only-hmr", + configResolved(config) { + // vite-plugin-solid reads these options on every transform, and uses the + // same condition to decide whether to enable hot reloading. + if ( + config.command === "serve" && + config.mode !== "production" && + options.hot !== false && + !("omitServerOnlyTemplates" in compilerOptions) + ) { + compilerOptions.omitServerOnlyTemplates = false; + } + }, + }; + + return [serverOnlyHmr, solid({ ...options, solid: compilerOptions })]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65b914178..6d07e7c49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1837,16 +1837,16 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - babel-plugin-jsx-dom-expressions@0.40.7: - resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + babel-plugin-jsx-dom-expressions@0.40.11: + resolution: {integrity: sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ==} peerDependencies: '@babel/core': ^7.20.12 - babel-preset-solid@1.9.12: - resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + babel-preset-solid@1.9.15: + resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==} peerDependencies: '@babel/core': ^7.0.0 - solid-js: ^1.9.12 + solid-js: ^1.9.15 peerDependenciesMeta: solid-js: optional: true @@ -4652,7 +4652,7 @@ snapshots: assertion-error@2.0.1: {} - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + babel-plugin-jsx-dom-expressions@0.40.11(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 @@ -4661,10 +4661,10 @@ snapshots: html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.15): + babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15): dependencies: '@babel/core': 7.29.7 - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + babel-plugin-jsx-dom-expressions: 0.40.11(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.15 @@ -5917,7 +5917,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.15) + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) merge-anything: 5.1.7 solid-js: 1.9.15 solid-refresh: 0.6.3(solid-js@1.9.15)