diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 13f8f14913..8a395321c4 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -1119,6 +1119,12 @@ A component can only have one `<%name%>` element Valid `` tag names are %list% ``` +### svelte_options_customrenderer_disabled + +``` +`customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled +``` + ### svelte_options_deprecated_tag ``` diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 768f907769..7fdb56ce55 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -429,6 +429,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro > Valid `` tag names are %list% +## svelte_options_customrenderer_disabled + +> `customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled + ## svelte_options_deprecated_tag > "tag" option is deprecated — use "customElement" instead diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 3b28d5e2a1..f43e8cae63 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1549,6 +1549,15 @@ export function svelte_meta_invalid_tag(node, list) { e(node, 'svelte_meta_invalid_tag', `Valid \`\` tag names are ${list}\nhttps://svelte.dev/e/svelte_meta_invalid_tag`); } +/** + * `customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function svelte_options_customrenderer_disabled(node) { + e(node, 'svelte_options_customrenderer_disabled', `\`customRenderer\` cannot be set in \`\` unless the \`experimental.customRenderer\` compiler option is enabled\nhttps://svelte.dev/e/svelte_options_customrenderer_disabled`); +} + /** * "tag" option is deprecated — use "customElement" instead * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index be1b17b1b1..a18e8bb864 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -10,6 +10,7 @@ import { analyze_component, analyze_module } from './phases/2-analyze/index.js'; import { transform_component, transform_module } from './phases/3-transform/index.js'; import { validate_component_options, validate_module_options } from './validate-options.js'; import * as state from './state.js'; +import * as e from './errors.js'; export { default as preprocess } from './preprocess/index.js'; export { print } from './print/index.js'; @@ -34,6 +35,32 @@ export function compile(source, options) { ...parsed_options } = parsed.options || {}; + // resolve the per-component custom renderer, taking `` + // into account. The normalized option is always a function returning `string | null | undefined` + // (see `validate-options.js`). A string opts in to a specific renderer module, `null`/`false` + // opts out to plain DOM (while keeping the feature enabled) and `true`/absent inherits whatever + // the global option resolves to. + let custom_renderer_option = validated.experimental.customRenderer; + + if (custom_renderer !== undefined) { + // the feature is a global compiler option — a component can only override the renderer it uses + // (or opt out) when `experimental.customRenderer` is enabled. Otherwise nothing pushes a + // renderer, so allowing `` would silently do the wrong thing. + if (!options.experimental?.customRenderer && parsed.options?.attributes) { + for (const attribute of parsed.options.attributes) { + if (attribute.name === 'customRenderer') { + e.svelte_options_customrenderer_disabled(attribute); + } + } + } + + if (typeof custom_renderer === 'string') { + custom_renderer_option = () => custom_renderer; + } else if (custom_renderer === false || custom_renderer === null) { + custom_renderer_option = () => null; + } + } + /** @type {ValidatedCompileOptions} */ const combined_options = { ...validated, @@ -43,7 +70,7 @@ export function compile(source, options) { runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes, experimental: { ...validated.experimental, - ...(custom_renderer !== undefined ? { customRenderer: () => custom_renderer } : {}) + customRenderer: custom_renderer_option } }; diff --git a/packages/svelte/src/compiler/phases/1-parse/read/options.js b/packages/svelte/src/compiler/phases/1-parse/read/options.js index 79f331b72d..847d7cf0b9 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/options.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/options.js @@ -197,7 +197,10 @@ export default function read_options(node) { } } - if (component_options.css === 'injected' && component_options.customRenderer !== undefined) { + if ( + component_options.css === 'injected' && + typeof component_options.customRenderer === 'string' + ) { // Find the css attribute node for the error position const css_attribute = node.attributes.find( (/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css' diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index d6eef085d1..5a7755a26e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -472,7 +472,9 @@ export function analyze_component(root, source, options) { const css = options.css({ filename: options.filename }); const custom_renderer = options.experimental.customRenderer?.({ filename: options.filename }); - if (css === 'injected' && custom_renderer !== undefined) { + // only an actual renderer module (a string) is incompatible with injected css — a `null` + // renderer means the component renders to the DOM, which is fine + if (css === 'injected' && typeof custom_renderer === 'string') { e.incompatible_with_custom_renderer(null, "`css: 'injected'`"); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index c749a4e38f..8bfc38f127 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -585,9 +585,14 @@ export function client_component(analysis, options) { component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id'))); } - if (custom_renderer) { + if (custom_renderer !== undefined) { + // when the custom renderer feature is enabled every component pushes a renderer: components + // with a renderer module push `$renderer`, DOM components push `null` component_block.body.unshift( - b.var('$$pop_renderer', b.call('$.push_renderer', b.id('$renderer'))) + b.var( + '$$pop_renderer', + b.call('$.push_renderer', custom_renderer ? b.id('$renderer') : b.literal(null)) + ) ); component_block.body.push(b.stmt(b.call('$$pop_renderer'))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index a354408686..216bf08b5c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -1,7 +1,7 @@ /** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, SourceLocation, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../../types.js' */ -import { dev, is_ignored, custom_renderer } from '../../../../../state.js'; +import { dev, is_ignored } from '../../../../../state.js'; import { get_attribute_chunks, object } from '../../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { add_svelte_meta, build_bind_this, Memoizer, validate_binding } from '../shared/utils.js'; @@ -456,13 +456,6 @@ export function build_component(node, component_name, loc, context) { }; } - if (custom_renderer) { - const prev = fn; - fn = (node_id) => { - return b.call('$.without_renderer', b.arrow([], prev(node_id))); - }; - } - if (node.type !== 'SvelteSelf') { // Component name itself could be blocked on async values memoizer.check_blockers(node.metadata.expression); diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js index 094c53473b..319009037a 100644 --- a/packages/svelte/src/compiler/state.js +++ b/packages/svelte/src/compiler/state.js @@ -46,8 +46,14 @@ export let dev; export let runes = false; -/** @type {string | null | undefined} */ -export let custom_renderer = null; +/** + * The custom renderer for the component currently being compiled: + * - `string`: the renderer module path (the component uses a custom renderer) + * - `null`: the custom renderer feature is enabled but this component renders to the DOM + * - `undefined`: the custom renderer feature is off + * @type {string | null | undefined} + */ +export let custom_renderer = undefined; /** @type {(index: number) => Location} */ export let locator; @@ -142,7 +148,7 @@ export function is_ignored(node, code) { export function reset(state) { dev = false; runes = false; - custom_renderer = null; + custom_renderer = undefined; component_name = UNKNOWN_FILENAME; source = ''; source_lines = []; diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 2813c782a9..d53221ff5f 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -239,18 +239,30 @@ export interface ModuleCompileOptions { */ async?: boolean; /** - * Path to a module that exports the custom renderer to use. When this is truthy templating mode will also be automatically set to `functional` + * Enables custom renderers to be specified with ``. Can be: + * + * - `true`, allowing components to individually opt in + * - a string that points to a default custom renderer module. Individual components can override the default, or opt out with `` + * - a function that receives a `{ filename }` object and returns a custom renderer module path, or `null` if no custom renderer should be used + * + * A custom renderer module's default export must be an object created with `createRenderer`. */ - customRenderer?: string | ((options: { filename: string }) => string | undefined); + customRenderer?: + | boolean + | string + | ((options: { filename: string }) => string | null | undefined); }; } // The following two somewhat scary looking types ensure that certain types are required but can be undefined still -export type ValidatedModuleCompileOptions = Omit, 'rootDir'> & { +export type ValidatedModuleCompileOptions = Omit< + Required, + 'rootDir' | 'experimental' +> & { rootDir: ModuleCompileOptions['rootDir']; experimental: Required['experimental'], 'customRenderer'>> & { - customRenderer: (options: { filename: string }) => string | undefined; + customRenderer: (options: { filename: string }) => string | null | undefined; }; }; diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 6d3ce17d03..358570db2a 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -85,7 +85,7 @@ export namespace AST { preserveWhitespace?: boolean; namespace?: Namespace; css?: 'injected'; - customRenderer?: string; + customRenderer?: string | boolean | null; customElement?: { tag?: string; shadow?: 'open' | 'none' | ObjectExpression | undefined; diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js index 674cad5b8c..362d1e7d9a 100644 --- a/packages/svelte/src/compiler/validate-options.js +++ b/packages/svelte/src/compiler/validate-options.js @@ -45,13 +45,34 @@ const common_options = { experimental: object({ async: boolean(false), - customRenderer: parametric( - /** @type {(options: { filename: string }) => string | undefined} */ (() => undefined), + // `customRenderer` can be: + // - `undefined`/`false`: the feature is off, components compile to plain DOM + // - `true`: the feature is on, every component defaults to DOM (opt in via ``) + // - a string: the feature is on, every component defaults to that module (opt out via ``) + // - a function: the feature is on, the module is resolved lazily per file + // The normalized value is always a function returning `string | null | undefined` where + // `string` is the renderer module, `null` is "DOM but feature enabled" (push `null`) and + // `undefined` is "feature off" (no renderer pushed at all). + customRenderer: validator( + /** @type {(options: { filename: string }) => string | null | undefined} */ (() => undefined), (input, keypath) => { - if (input != null && typeof input !== 'string') { - throw_error(`${keypath} should be a string, if specified`); + if (input === false) { + return () => undefined; } - return /** @type {string | undefined} */ (input); + + if (input === true) { + return () => null; + } + + if (typeof input === 'string') { + return () => input; + } + + if (typeof input === 'function') { + return input; + } + + throw_error(`${keypath} should be true, a string or a function, if specified`); } ) }) diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js index a952ccbd34..007da9ee8d 100644 --- a/packages/svelte/src/internal/client/custom-renderer/state.js +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -35,23 +35,3 @@ export function push_renderer(value) { } }; } - -/** - * @template T - * @param {() => T} fn - * @returns {T} - */ -export function without_renderer(fn) { - if (current_renderer === null) { - return fn(); - } - - var previous_renderer = current_renderer; - current_renderer = null; - - try { - return fn(); - } finally { - current_renderer = previous_renderer; - } -} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 1ea4269ab4..de99e34401 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -186,4 +186,4 @@ export { export { strict_equals, equals } from './dev/equality.js'; export { log_if_contains_state } from './dev/console-log.js'; export { invoke_error_boundary } from './error-handling.js'; -export { push_renderer, without_renderer } from './custom-renderer/state.js'; +export { push_renderer } from './custom-renderer/state.js'; diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json new file mode 100644 index 0000000000..d1bf454457 --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "svelte_options_customrenderer_disabled", + "message": "`customRenderer` cannot be set in `` unless the `experimental.customRenderer` compiler option is enabled", + "start": { + "line": 1, + "column": 16 + }, + "end": { + "line": 1, + "column": 43 + } + } +] diff --git a/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte new file mode 100644 index 0000000000..cf7cf1a5b0 --- /dev/null +++ b/packages/svelte/tests/validator/samples/svelte-options-customrenderer-disabled/input.svelte @@ -0,0 +1,3 @@ + + +
hello
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 44277c647e..0eca28b442 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1196,9 +1196,18 @@ declare module 'svelte/compiler' { */ async?: boolean; /** - * Path to a module that exports the custom renderer to use. When this is truthy templating mode will also be automatically set to `functional` + * Enables custom renderers to be specified with ``. Can be: + * + * - `true`, allowing components to individually opt in + * - a string that points to a default custom renderer module. Individual components can override the default, or opt out with `` + * - a function that receives a `{ filename }` object and returns a custom renderer module path, or `null` if no custom renderer should be used + * + * A custom renderer module's default export must be an object created with `createRenderer`. */ - customRenderer?: string | ((options: { filename: string }) => string | undefined); + customRenderer?: + | boolean + | string + | ((options: { filename: string }) => string | null | undefined); }; } /** @@ -1248,7 +1257,7 @@ declare module 'svelte/compiler' { preserveWhitespace?: boolean; namespace?: Namespace; css?: 'injected'; - customRenderer?: string; + customRenderer?: string | boolean | null; customElement?: { tag?: string; shadow?: 'open' | 'none' | ObjectExpression | undefined; @@ -3516,9 +3525,18 @@ declare module 'svelte/types/compiler/interfaces' { */ async?: boolean; /** - * Path to a module that exports the custom renderer to use. When this is truthy templating mode will also be automatically set to `functional` + * Enables custom renderers to be specified with ``. Can be: + * + * - `true`, allowing components to individually opt in + * - a string that points to a default custom renderer module. Individual components can override the default, or opt out with `` + * - a function that receives a `{ filename }` object and returns a custom renderer module path, or `null` if no custom renderer should be used + * + * A custom renderer module's default export must be an object created with `createRenderer`. */ - customRenderer?: string | ((options: { filename: string }) => string | undefined); + customRenderer?: + | boolean + | string + | ((options: { filename: string }) => string | null | undefined); }; } /**