diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 739bd58b35..0ea094649c 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -1,5 +1,13 @@ +### custom_renderer_unavailable_on_server + +``` +You need to specify a custom condition to use a custom renderer in a non-browser environment such as Node. Add the `custom-renderer` resolve condition (eg. run Node with `--conditions=custom-renderer`). + +If you're using a bundler, add `custom-renderer` to its list of resolve conditions instead. +``` + ### experimental_async_required ``` diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index 3ff474768e..e437c99f4c 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -1,3 +1,9 @@ +## custom_renderer_unavailable_on_server + +> You need to specify a custom condition to use a custom renderer in a non-browser environment such as Node. Add the `custom-renderer` resolve condition (eg. run Node with `--conditions=custom-renderer`). +> +> If you're using a bundler, add `custom-renderer` to its list of resolve conditions instead. + ## experimental_async_required > Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5ef2e1b52a..35b933f23e 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -22,6 +22,7 @@ "exports": { ".": { "types": "./types/index.d.ts", + "custom-renderer": "./src/index-client.js", "worker": "./src/index-server.js", "browser": "./src/index-client.js", "default": "./src/index-server.js" @@ -72,13 +73,16 @@ "default": "./src/internal/flags/tracing.js" }, "./internal/flags/custom-renderer": { - "default": "./src/internal/flags/custom-renderer.js" + "custom-renderer": "./src/internal/flags/custom-renderer.js", + "browser": "./src/internal/flags/custom-renderer.js", + "default": "./src/internal/flags/custom-renderer-server.js" }, "./internal/server": { "default": "./src/internal/server/index.js" }, "./legacy": { "types": "./types/index.d.ts", + "custom-renderer": "./src/legacy/legacy-client.js", "worker": "./src/legacy/legacy-server.js", "browser": "./src/legacy/legacy-client.js", "default": "./src/legacy/legacy-server.js" @@ -89,6 +93,7 @@ }, "./reactivity": { "types": "./types/index.d.ts", + "custom-renderer": "./src/reactivity/index-client.js", "worker": "./src/reactivity/index-server.js", "browser": "./src/reactivity/index-client.js", "default": "./src/reactivity/index-server.js" @@ -99,7 +104,9 @@ }, "./renderer": { "types": "./types/index.d.ts", - "default": "./src/renderer/index.js" + "custom-renderer": "./src/renderer/index.js", + "browser": "./src/renderer/index.js", + "default": "./src/renderer/index-server.js" }, "./server": { "types": "./types/index.d.ts", @@ -107,6 +114,7 @@ }, "./store": { "types": "./types/index.d.ts", + "custom-renderer": "./src/store/index-client.js", "worker": "./src/store/index-server.js", "browser": "./src/store/index-client.js", "default": "./src/store/index-server.js" diff --git a/packages/svelte/src/internal/flags/custom-renderer-server.js b/packages/svelte/src/internal/flags/custom-renderer-server.js new file mode 100644 index 0000000000..634c6d7147 --- /dev/null +++ b/packages/svelte/src/internal/flags/custom-renderer-server.js @@ -0,0 +1,10 @@ +import * as e from '../shared/errors.js'; + +// This module is injected by the compiler into every component when the custom +// renderer feature is enabled. It is only ever imported as a package specifier +// (`svelte/internal/flags/custom-renderer`) from compiled client output, which +// must run where Svelte's client build is available — i.e. with the `browser` +// or `custom-renderer` resolve condition set. If we end up here, the component +// is running in a server context without that condition, so fail fast with the +// same descriptive error as `svelte/renderer`. +e.custom_renderer_unavailable_on_server(); diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index a4ad8cecff..905166c8f2 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -2,6 +2,26 @@ import { DEV } from 'esm-env'; +/** + * You need to specify a custom condition to use a custom renderer in a non-browser environment such as Node. Add the `custom-renderer` resolve condition (eg. run Node with `--conditions=custom-renderer`). + * + * If you're using a bundler, add `custom-renderer` to its list of resolve conditions instead. + * @returns {never} + */ +export function custom_renderer_unavailable_on_server() { + if (DEV) { + const error = new Error(`custom_renderer_unavailable_on_server\nYou need to specify a custom condition to use a custom renderer in a non-browser environment such as Node. Add the \`custom-renderer\` resolve condition (eg. run Node with \`--conditions=custom-renderer\`). + +If you're using a bundler, add \`custom-renderer\` to its list of resolve conditions instead.\nhttps://svelte.dev/e/custom_renderer_unavailable_on_server`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/custom_renderer_unavailable_on_server`); + } +} + /** * Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` * @param {string} name diff --git a/packages/svelte/src/renderer/index-server.js b/packages/svelte/src/renderer/index-server.js new file mode 100644 index 0000000000..59bb8e0fde --- /dev/null +++ b/packages/svelte/src/renderer/index-server.js @@ -0,0 +1,18 @@ +import * as e from '../internal/shared/errors.js'; + +/** + * Server-side stub for `svelte/renderer`. Custom renderers rely on Svelte's + * client build (the same one selected by the `browser` export condition), which + * is not available in a server context. Calling `createRenderer` here throws a + * descriptive error pointing users at the `custom-renderer` resolve condition + * rather than letting them fail later with a cryptic `mount` error. + * + * The parameter mirrors the real `createRenderer` signature so this stub is a + * drop-in replacement; it is intentionally unused. + * + * @param {unknown} [_renderer] + * @returns {never} + */ +export function createRenderer(_renderer) { + e.custom_renderer_unavailable_on_server(); +} diff --git a/packages/svelte/tests/custom-renderers-server/test.ts b/packages/svelte/tests/custom-renderers-server/test.ts new file mode 100644 index 0000000000..06b1aaf5e6 --- /dev/null +++ b/packages/svelte/tests/custom-renderers-server/test.ts @@ -0,0 +1,25 @@ +// @vitest-environment node + +// This suite runs in a dedicated Vitest project (see the root `vitest.config.js` +// `projects` array) that resolves `svelte` using only the server/`default` +// export condition — i.e. it simulates running a custom renderer in Node +// *without* the required `custom-renderer` (or `browser`) resolve condition. +// In that situation Svelte should fail fast with a descriptive error rather +// than letting the user hit a cryptic `mount` failure later on. + +import { expect, test } from 'vitest'; + +test('`createRenderer` from `svelte/renderer` throws a descriptive error', async () => { + const { createRenderer } = await import('svelte/renderer'); + + // @ts-expect-error - we are testing that this throws, so we don't care about the type signature + expect(() => createRenderer({})).toThrow(/custom_renderer_unavailable_on_server/); +}); + +test('importing the custom renderer flag module throws a descriptive error', async () => { + // the compiler injects `import 'svelte/internal/flags/custom-renderer'` into + // every component when the feature is enabled, so this must fail loudly too + await expect(import('svelte/internal/flags/custom-renderer')).rejects.toThrow( + /custom_renderer_unavailable_on_server/ + ); +}); diff --git a/vitest.config.js b/vitest.config.js index 116dfae9d8..3a17323cfb 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,28 +4,42 @@ import { configDefaults, defineConfig } from 'vitest/config'; const pkg = JSON.parse(fs.readFileSync('packages/svelte/package.json', 'utf8')); -export default defineConfig({ - resolve: { - alias: [ - { - find: /^svelte\/?/, - customResolver: (id, importer) => { - // For some reason this turns up as "undefined" instead of "svelte/" - const exported = pkg.exports[id === 'undefined' ? '.' : id.replace('undefined', './')]; - if (!exported) return; +/** + * Resolves `svelte` / `svelte/*` imports against the package's `exports` map. + * + * @param {boolean} force_server When `true`, always resolve to the server + * (`default`) condition. This is used by the `custom-renderer-server` project + * to simulate running in Node *without* the `custom-renderer`/`browser` + * resolve condition. Otherwise the client (`browser`) build is preferred, + * except for compiled SSR output (`_output/server`). + */ +function svelte_alias(force_server = false) { + return [ + { + find: /^svelte\/?/, + /** + * @param {string} id + * @param {string | undefined} importer + */ + customResolver: (id, importer) => { + // For some reason this turns up as "undefined" instead of "svelte/" + const exported = pkg.exports[id === 'undefined' ? '.' : id.replace('undefined', './')]; + if (!exported) return; - // When running the server version of the Svelte files, - // we also want to use the server export of the Svelte package - return path.resolve( - 'packages/svelte', - importer?.includes('_output/server') - ? exported.default - : exported.browser ?? exported.default - ); - } + // When running the server version of the Svelte files, + // we also want to use the server export of the Svelte package + const use_server = force_server || importer?.includes('_output/server'); + + return path.resolve( + 'packages/svelte', + use_server ? exported.default : exported.browser ?? exported.default + ); } - ] - }, + } + ]; +} + +export default defineConfig({ test: { dir: '.', reporters: ['dot'], @@ -34,18 +48,42 @@ export default defineConfig({ // slow enough under vitest 4's deeper async stacks (and CI's slower workers) // to overrun the 5s default. testTimeout: 30_000, - include: [ - 'packages/svelte/**/*.test.ts', - 'packages/svelte/tests/*/test.ts', - 'packages/svelte/tests/runtime-browser/test-ssr.ts' - ], - exclude: [...configDefaults.exclude, '**/samples/**'], coverage: { provider: 'v8', reporter: ['lcov', 'html'], include: ['packages/svelte/src/**'], reportsDirectory: 'coverage', reportOnFailure: true - } + }, + projects: [ + { + extends: true, + resolve: { alias: svelte_alias() }, + test: { + name: 'svelte', + include: [ + 'packages/svelte/**/*.test.ts', + 'packages/svelte/tests/*/test.ts', + 'packages/svelte/tests/runtime-browser/test-ssr.ts' + ], + exclude: [ + ...configDefaults.exclude, + '**/samples/**', + // runs in its own project with the server resolve condition + 'packages/svelte/tests/custom-renderers-server/**' + ] + } + }, + { + extends: true, + // resolve `svelte` as if no `custom-renderer`/`browser` condition was set + resolve: { alias: svelte_alias(true) }, + test: { + name: { label: 'custom-renderer-server', color: 'red' }, + environment: 'node', + include: ['packages/svelte/tests/custom-renderers-server/test.ts'] + } + } + ] } });