fix: `customRenderer` as a global option and remove `without_renderer` (#18359)

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
pull/18505/head
Paolo Ricciuti 3 months ago committed by GitHub
parent e5beb7297d
commit 56856f0b10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1119,6 +1119,12 @@ A component can only have one `<%name%>` element
Valid `<svelte:...>` tag names are %list% Valid `<svelte:...>` tag names are %list%
``` ```
### svelte_options_customrenderer_disabled
```
`customRenderer` cannot be set in `<svelte:options>` unless the `experimental.customRenderer` compiler option is enabled
```
### svelte_options_deprecated_tag ### svelte_options_deprecated_tag
``` ```

@ -429,6 +429,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro
> Valid `<svelte:...>` tag names are %list% > Valid `<svelte:...>` tag names are %list%
## svelte_options_customrenderer_disabled
> `customRenderer` cannot be set in `<svelte:options>` unless the `experimental.customRenderer` compiler option is enabled
## svelte_options_deprecated_tag ## svelte_options_deprecated_tag
> "tag" option is deprecated — use "customElement" instead > "tag" option is deprecated — use "customElement" instead

@ -1549,6 +1549,15 @@ export function svelte_meta_invalid_tag(node, list) {
e(node, 'svelte_meta_invalid_tag', `Valid \`<svelte:...>\` tag names are ${list}\nhttps://svelte.dev/e/svelte_meta_invalid_tag`); e(node, 'svelte_meta_invalid_tag', `Valid \`<svelte:...>\` tag names are ${list}\nhttps://svelte.dev/e/svelte_meta_invalid_tag`);
} }
/**
* `customRenderer` cannot be set in `<svelte:options>` 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 \`<svelte:options>\` unless the \`experimental.customRenderer\` compiler option is enabled\nhttps://svelte.dev/e/svelte_options_customrenderer_disabled`);
}
/** /**
* "tag" option is deprecated use "customElement" instead * "tag" option is deprecated use "customElement" instead
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

@ -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 { transform_component, transform_module } from './phases/3-transform/index.js';
import { validate_component_options, validate_module_options } from './validate-options.js'; import { validate_component_options, validate_module_options } from './validate-options.js';
import * as state from './state.js'; import * as state from './state.js';
import * as e from './errors.js';
export { default as preprocess } from './preprocess/index.js'; export { default as preprocess } from './preprocess/index.js';
export { print } from './print/index.js'; export { print } from './print/index.js';
@ -34,6 +35,32 @@ export function compile(source, options) {
...parsed_options ...parsed_options
} = parsed.options || {}; } = parsed.options || {};
// resolve the per-component custom renderer, taking `<svelte:options customRenderer={...} />`
// 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 `<svelte:options customRenderer>` 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} */ /** @type {ValidatedCompileOptions} */
const combined_options = { const combined_options = {
...validated, ...validated,
@ -43,7 +70,7 @@ export function compile(source, options) {
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes, runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes,
experimental: { experimental: {
...validated.experimental, ...validated.experimental,
...(custom_renderer !== undefined ? { customRenderer: () => custom_renderer } : {}) customRenderer: custom_renderer_option
} }
}; };

@ -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 // Find the css attribute node for the error position
const css_attribute = node.attributes.find( const css_attribute = node.attributes.find(
(/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css' (/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css'

@ -472,7 +472,9 @@ export function analyze_component(root, source, options) {
const css = options.css({ filename: options.filename }); const css = options.css({ filename: options.filename });
const custom_renderer = options.experimental.customRenderer?.({ 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'`"); e.incompatible_with_custom_renderer(null, "`css: 'injected'`");
} }

@ -585,9 +585,14 @@ export function client_component(analysis, options) {
component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id'))); 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( 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'))); component_block.body.push(b.stmt(b.call('$$pop_renderer')));
} }

@ -1,7 +1,7 @@
/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, SourceLocation, Statement } from 'estree' */ /** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, SourceLocation, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */ /** @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 { get_attribute_chunks, object } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { add_svelte_meta, build_bind_this, Memoizer, validate_binding } from '../shared/utils.js'; 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') { if (node.type !== 'SvelteSelf') {
// Component name itself could be blocked on async values // Component name itself could be blocked on async values
memoizer.check_blockers(node.metadata.expression); memoizer.check_blockers(node.metadata.expression);

@ -46,8 +46,14 @@ export let dev;
export let runes = false; 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} */ /** @type {(index: number) => Location} */
export let locator; export let locator;
@ -142,7 +148,7 @@ export function is_ignored(node, code) {
export function reset(state) { export function reset(state) {
dev = false; dev = false;
runes = false; runes = false;
custom_renderer = null; custom_renderer = undefined;
component_name = UNKNOWN_FILENAME; component_name = UNKNOWN_FILENAME;
source = ''; source = '';
source_lines = []; source_lines = [];

@ -239,18 +239,30 @@ export interface ModuleCompileOptions {
*/ */
async?: boolean; 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 `<svelte:options customRenderer="path/to/renderer/module" />`. 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 `<svelte:options customRenderer={null} />`
* - 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 // The following two somewhat scary looking types ensure that certain types are required but can be undefined still
export type ValidatedModuleCompileOptions = Omit<Required<ModuleCompileOptions>, 'rootDir'> & { export type ValidatedModuleCompileOptions = Omit<
Required<ModuleCompileOptions>,
'rootDir' | 'experimental'
> & {
rootDir: ModuleCompileOptions['rootDir']; rootDir: ModuleCompileOptions['rootDir'];
experimental: Required<Omit<Required<ModuleCompileOptions>['experimental'], 'customRenderer'>> & { experimental: Required<Omit<Required<ModuleCompileOptions>['experimental'], 'customRenderer'>> & {
customRenderer: (options: { filename: string }) => string | undefined; customRenderer: (options: { filename: string }) => string | null | undefined;
}; };
}; };

@ -85,7 +85,7 @@ export namespace AST {
preserveWhitespace?: boolean; preserveWhitespace?: boolean;
namespace?: Namespace; namespace?: Namespace;
css?: 'injected'; css?: 'injected';
customRenderer?: string; customRenderer?: string | boolean | null;
customElement?: { customElement?: {
tag?: string; tag?: string;
shadow?: 'open' | 'none' | ObjectExpression | undefined; shadow?: 'open' | 'none' | ObjectExpression | undefined;

@ -45,13 +45,34 @@ const common_options = {
experimental: object({ experimental: object({
async: boolean(false), async: boolean(false),
customRenderer: parametric( // `customRenderer` can be:
/** @type {(options: { filename: string }) => string | undefined} */ (() => undefined), // - `undefined`/`false`: the feature is off, components compile to plain DOM
// - `true`: the feature is on, every component defaults to DOM (opt in via `<svelte:options>`)
// - a string: the feature is on, every component defaults to that module (opt out via `<svelte:options>`)
// - 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) => { (input, keypath) => {
if (input != null && typeof input !== 'string') { if (input === false) {
throw_error(`${keypath} should be a string, if specified`); return () => undefined;
}
if (input === true) {
return () => null;
}
if (typeof input === 'string') {
return () => input;
} }
return /** @type {string | undefined} */ (input);
if (typeof input === 'function') {
return input;
}
throw_error(`${keypath} should be true, a string or a function, if specified`);
} }
) )
}) })

@ -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;
}
}

@ -186,4 +186,4 @@ export {
export { strict_equals, equals } from './dev/equality.js'; export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js'; export { log_if_contains_state } from './dev/console-log.js';
export { invoke_error_boundary } from './error-handling.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';

@ -0,0 +1,3 @@
import { test } from '../../test';
export default test({});

@ -0,0 +1,14 @@
[
{
"code": "svelte_options_customrenderer_disabled",
"message": "`customRenderer` cannot be set in `<svelte:options>` unless the `experimental.customRenderer` compiler option is enabled",
"start": {
"line": 1,
"column": 16
},
"end": {
"line": 1,
"column": 43
}
}
]

@ -0,0 +1,3 @@
<svelte:options customRenderer="./renderer" />
<div>hello</div>

@ -1196,9 +1196,18 @@ declare module 'svelte/compiler' {
*/ */
async?: boolean; 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 `<svelte:options customRenderer="path/to/renderer/module" />`. 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 `<svelte:options customRenderer={null} />`
* - 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; preserveWhitespace?: boolean;
namespace?: Namespace; namespace?: Namespace;
css?: 'injected'; css?: 'injected';
customRenderer?: string; customRenderer?: string | boolean | null;
customElement?: { customElement?: {
tag?: string; tag?: string;
shadow?: 'open' | 'none' | ObjectExpression | undefined; shadow?: 'open' | 'none' | ObjectExpression | undefined;
@ -3516,9 +3525,18 @@ declare module 'svelte/types/compiler/interfaces' {
*/ */
async?: boolean; 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 `<svelte:options customRenderer="path/to/renderer/module" />`. 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 `<svelte:options customRenderer={null} />`
* - 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);
}; };
} }
/** /**

Loading…
Cancel
Save