pull/18042/merge
Paolo Ricciuti 11 hours ago committed by GitHub
commit 3ed36ecc7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: custom renderer api

@ -197,6 +197,12 @@ Failed to hydrate the application
Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`
```
### invalid_snippet_in_custom_renderer
```
`createRawSnippet` cannot be used with a custom renderer
```
### lifecycle_legacy_only
```
@ -229,6 +235,12 @@ The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### snippet_renderer_mismatch
```
A snippet created in a component with a custom renderer cannot be rendered by a different renderer
```
### state_descriptors_fixed
```

@ -605,6 +605,12 @@ Cannot use `await` in deriveds and template expressions, or at the top level of
Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case
```
### incompatible_with_custom_renderer
```
%message% is not compatible with `customRenderer`
```
### inspect_trace_generator
```

@ -151,6 +151,10 @@ This can happen if you render a hydratable on the client that was not rendered o
> Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`
## invalid_snippet_in_custom_renderer
> `createRawSnippet` cannot be used with a custom renderer
## lifecycle_legacy_only
> `%name%(...)` cannot be used in runes mode
@ -173,6 +177,10 @@ This can happen if you render a hydratable on the client that was not rendered o
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## snippet_renderer_mismatch
> A snippet created in a component with a custom renderer cannot be rendered by a different renderer
## state_descriptors_fixed
> Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.

@ -263,6 +263,10 @@ The same applies to components:
> `<%name%>` does not support non-event attributes or spread attributes
## incompatible_with_custom_renderer
> %message% is not compatible with `customRenderer`
## js_parse_error
> %message%

@ -59,6 +59,9 @@
"./internal/disclose-version": {
"default": "./src/internal/disclose-version.js"
},
"./internal/init-operations": {
"default": "./src/internal/init-operations.js"
},
"./internal/flags/async": {
"default": "./src/internal/flags/async.js"
},
@ -91,6 +94,10 @@
"types": "./types/index.d.ts",
"default": "./src/reactivity/window/index.js"
},
"./renderer": {
"types": "./types/index.d.ts",
"default": "./src/renderer/index.js"
},
"./server": {
"types": "./types/index.d.ts",
"default": "./src/server/index.js"

@ -0,0 +1 @@
import './types/index.js';

@ -8,7 +8,16 @@ const pkg = JSON.parse(fs.readFileSync(`${dir}/package.json`, 'utf-8'));
// For people not using moduleResolution: 'bundler', we need to generate these files. Think about removing this in Svelte 6 or 7
// It may look weird, but the imports MUST be ending with index.js to be properly resolved in all TS modes
for (const name of ['action', 'animate', 'easing', 'motion', 'store', 'transition', 'legacy']) {
for (const name of [
'action',
'animate',
'easing',
'motion',
'store',
'transition',
'legacy',
'renderer'
]) {
fs.writeFileSync(`${dir}/${name}.d.ts`, "import './types/index.js';\n");
}
@ -44,6 +53,7 @@ await createBundle({
[`${pkg.name}/motion`]: `${dir}/src/motion/public.d.ts`,
[`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`,
[`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`,
[`${pkg.name}/renderer`]: `${dir}/src/internal/client/custom-renderer/index.js`,
[`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`,
[`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`,
[`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`,

@ -1176,6 +1176,16 @@ export function illegal_element_attribute(node, name) {
e(node, 'illegal_element_attribute', `\`<${name}>\` does not support non-event attributes or spread attributes\nhttps://svelte.dev/e/illegal_element_attribute`);
}
/**
* %message% is not compatible with `customRenderer`
* @param {null | number | NodeLike} node
* @param {string} message
* @returns {never}
*/
export function incompatible_with_custom_renderer(node, message) {
e(node, 'incompatible_with_custom_renderer', `${message} is not compatible with \`customRenderer\`\nhttps://svelte.dev/e/incompatible_with_custom_renderer`);
}
/**
* %message%
* @param {null | number | NodeLike} node

@ -28,7 +28,11 @@ export function compile(source, options) {
let parsed = _parse(source);
const { customElement: customElementOptions, ...parsed_options } = parsed.options || {};
const {
customElement: customElementOptions,
customRenderer: custom_renderer,
...parsed_options
} = parsed.options || {};
/** @type {ValidatedCompileOptions} */
const combined_options = {
@ -36,7 +40,11 @@ export function compile(source, options) {
...parsed_options,
customElementOptions,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes,
experimental: {
...validated.experimental,
...(custom_renderer !== undefined ? { customRenderer: () => custom_renderer } : {})
}
};
if (parsed.metadata.ts) {

@ -149,7 +149,8 @@ export function migrate(source, { filename, use_ts } = {}) {
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external',
runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined,
experimental: {
async: true
async: true,
customRenderer: () => undefined
}
};

@ -36,6 +36,10 @@ export default function read_options(node) {
e.svelte_options_deprecated_tag(attribute);
break; // eslint doesn't know this is unnecessary
}
case 'customRenderer': {
component_options.customRenderer = get_static_value(attribute);
break;
}
case 'customElement': {
/** @type {AST.SvelteOptions['customElement']} */
const ce = {};
@ -193,6 +197,14 @@ export default function read_options(node) {
}
}
if (component_options.css === 'injected' && component_options.customRenderer !== undefined) {
// Find the css attribute node for the error position
const css_attribute = node.attributes.find(
(/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css'
);
e.incompatible_with_custom_renderer(css_attribute ?? node, "`css: 'injected'`");
}
return component_options;
}

@ -467,6 +467,12 @@ export function analyze_component(root, source, options) {
const custom_element_from_option = options.customElement({ filename: options.filename });
const css = options.css({ filename: options.filename });
const custom_renderer = options.experimental.customRenderer?.({ filename: options.filename });
if (css === 'injected' && custom_renderer !== undefined) {
e.incompatible_with_custom_renderer(null, "`css: 'injected'`");
}
const custom_element = options.customElementOptions ?? custom_element_from_option;
const is_custom_element = !!options.customElementOptions || custom_element_from_option;
@ -476,7 +482,8 @@ export function analyze_component(root, source, options) {
component_name: name,
dev: options.dev,
rootDir: options.rootDir,
runes
runes,
custom_renderer: custom_renderer
});
// TODO remove all the ?? stuff, we don't need it now that we're validating the config

@ -1,12 +1,17 @@
/** @import { Context } from '../types' */
/** @import { AST } from '#compiler'; */
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
/**
* @param {AST.AnimateDirective} node
* @param {Context} context
*/
export function AnimateDirective(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`animate:`');
}
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {

@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { cannot_be_set_statically, can_delegate_event } from '../../../../utils.js';
import { get_attribute_chunks, is_event_attribute } from '../../../utils/ast.js';
import { custom_renderer } from '../../../state.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
@ -59,8 +60,11 @@ export function Attribute(node, context) {
context.state.analysis.uses_event_attributes = true;
}
node.metadata.delegated =
parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2));
// we can't delegate event handlers in a non dom environment
if (!custom_renderer) {
node.metadata.delegated =
parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2));
}
}
}
}

@ -12,6 +12,7 @@ import { binding_properties } from '../../bindings.js';
import fuzzymatch from '../../1-parse/utils/fuzzymatch.js';
import { is_content_editable_binding, is_svg } from '../../../../utils.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { custom_renderer } from '../../../state.js';
/**
* @param {AST.BindDirective} node
@ -27,6 +28,9 @@ export function BindDirective(node, context) {
parent?.type === 'SvelteDocument' ||
parent?.type === 'SvelteBody'
) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`bind:`');
}
if (node.name in binding_properties) {
const property = binding_properties[node.name];
if (property.valid_elements && !property.valid_elements.includes(parent.name)) {

@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
@ -11,7 +12,7 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
export function ExpressionTag(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';
if (in_template && context.state.parent_element) {
if (in_template && context.state.parent_element && !custom_renderer) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);

@ -2,12 +2,17 @@
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import { validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
/**
* @param {AST.HtmlTag} node
* @param {Context} context
*/
export function HtmlTag(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`@html`');
}
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '@');
}

@ -1,6 +1,8 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { custom_renderer } from '../../../state.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
@ -8,6 +10,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
* @param {Context} context
*/
export function OnDirective(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`on:`');
}
if (context.state.analysis.runes) {
const parent_type = context.path.at(-1)?.type;

@ -17,7 +17,7 @@ import { check_element } from './shared/a11y/index.js';
import { validate_element } from './shared/element.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { object } from '../../../utils/ast.js';
import { runes } from '../../../state.js';
import { runes, custom_renderer } from '../../../state.js';
/**
* @param {AST.RegularElement} node
@ -25,7 +25,10 @@ import { runes } from '../../../state.js';
*/
export function RegularElement(node, context) {
validate_element(node, context);
check_element(node, context);
if (!custom_renderer) {
check_element(node, context);
}
node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);
@ -34,7 +37,9 @@ export function RegularElement(node, context) {
if (node.name === 'textarea' && node.fragment.nodes.length > 0) {
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute' && attribute.name === 'value') {
e.textarea_invalid_content(node);
if (!custom_renderer) {
e.textarea_invalid_content(node);
}
}
}
@ -110,7 +115,10 @@ export function RegularElement(node, context) {
// Special case: <select>, <option> or <optgroup> with rich content needs special hydration handling
// We mark the subtree as dynamic so parent elements properly include the child init code
if (is_customizable_select_element(node) || node.name === 'selectedcontent') {
if (
(is_customizable_select_element(node) || node.name === 'selectedcontent') &&
!custom_renderer
) {
// Mark the element's own fragment as dynamic so it's not treated as static
node.fragment.metadata.dynamic = true;
// Also mark ancestor fragments so parents properly include the child init code
@ -157,7 +165,7 @@ export function RegularElement(node, context) {
mark_subtree_dynamic(context.path);
}
if (context.state.parent_element) {
if (context.state.parent_element && !custom_renderer) {
let past_parent = false;
let only_warn = false;
const ancestors = [context.state.parent_element];
@ -218,7 +226,8 @@ export function RegularElement(node, context) {
context.state.analysis.source[node.end - 2] === '/' &&
!is_void(node_name) &&
!is_svg(node_name) &&
!is_mathml(node_name)
!is_mathml(node_name) &&
!custom_renderer
) {
w.element_invalid_self_closing_tag(node, node.name);
}

@ -1,6 +1,7 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { is_event_attribute } from '../../../utils/ast.js';
import { disallow_children } from './shared/special-element.js';
@ -9,6 +10,10 @@ import { disallow_children } from './shared/special-element.js';
* @param {Context} context
*/
export function SvelteBody(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:body>`');
}
disallow_children(node);
for (const attribute of node.attributes) {
if (

@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { is_event_attribute } from '../../../utils/ast.js';
/**
@ -9,6 +10,10 @@ import { is_event_attribute } from '../../../utils/ast.js';
* @param {Context} context
*/
export function SvelteDocument(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:document>`');
}
disallow_children(node);
for (const attribute of node.attributes) {

@ -5,6 +5,7 @@ import { is_text_attribute } from '../../../utils/ast.js';
import { check_element } from './shared/a11y/index.js';
import { validate_element } from './shared/element.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { custom_renderer } from '../../../state.js';
/**
* @param {AST.SvelteElement} node
@ -12,7 +13,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
*/
export function SvelteElement(node, context) {
validate_element(node, context);
check_element(node, context);
if (!custom_renderer) {
check_element(node, context);
}
node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);

@ -1,6 +1,7 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
@ -8,6 +9,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
* @param {Context} context
*/
export function SvelteHead(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:head>`');
}
for (const attribute of node.attributes) {
e.svelte_head_illegal_attribute(attribute);
}

@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { is_event_attribute } from '../../../utils/ast.js';
/**
@ -9,6 +10,10 @@ import { is_event_attribute } from '../../../utils/ast.js';
* @param {Context} context
*/
export function SvelteWindow(node, context) {
if (custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:window>`');
}
disallow_children(node);
for (const attribute of node.attributes) {

@ -4,6 +4,7 @@ import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import { regex_bidirectional_control_characters, regex_not_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { custom_renderer } from '../../../state.js';
import { extract_svelte_ignore } from '../../../utils/extract_svelte_ignore.js';
/**
@ -16,7 +17,8 @@ export function Text(node, context) {
if (
parent.type === 'Fragment' &&
context.state.parent_element &&
regex_not_whitespace.test(node.data)
regex_not_whitespace.test(node.data) &&
!custom_renderer
) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {

@ -1,6 +1,7 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { custom_renderer } from '../../../state.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
@ -9,6 +10,11 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
* @param {Context} context
*/
export function TransitionDirective(node, context) {
if (custom_renderer) {
const directive = node.intro && node.outro ? '`transition:`' : node.intro ? '`in:`' : '`out:`';
e.incompatible_with_custom_renderer(node, directive);
}
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });

@ -4,6 +4,7 @@ import { get_attribute_expression, is_expression_attribute } from '../../../../u
import { regex_illegal_attribute_character } from '../../../patterns.js';
import * as e from '../../../../errors.js';
import * as w from '../../../../warnings.js';
import { custom_renderer } from '../../../../state.js';
import {
validate_attribute,
validate_attribute_name,
@ -79,12 +80,12 @@ export function validate_element(node, context) {
validate_slot_attribute(context, attribute);
}
if (attribute.name === 'is') {
if (attribute.name === 'is' && !custom_renderer) {
w.attribute_avoid_is(attribute);
}
const correct_name = react_attributes.get(attribute.name);
if (correct_name) {
if (correct_name && !custom_renderer) {
w.attribute_invalid_property_name(attribute, attribute.name, correct_name);
}

@ -6,7 +6,7 @@ import { walk } from 'zimmerframe';
import * as b from '#compiler/builders';
import { build_getter, is_state_source } from './utils.js';
import { render_stylesheet } from '../css/index.js';
import { dev, filename } from '../../../state.js';
import { dev, filename, custom_renderer } from '../../../state.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
@ -138,6 +138,14 @@ const visitors = {
VariableDeclaration
};
function get_imports() {
const imports = [b.import_all('$', 'svelte/internal/client')];
if (custom_renderer) {
imports.push(b.imports([['$renderer', '$renderer', true]], custom_renderer));
}
return imports;
}
/**
* @param {ComponentAnalysis} analysis
* @param {ValidatedCompileOptions} options
@ -151,7 +159,7 @@ export function client_component(analysis, options) {
scope: analysis.module.scope,
scopes: analysis.module.scopes,
is_instance: false,
hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted],
hoisted: [...get_imports(), ...analysis.instance_body.hoisted],
node: /** @type {any} */ (null), // populated by the root node
legacy_reactive_imports: [],
legacy_reactive_statements: new Map(),
@ -566,6 +574,10 @@ export function client_component(analysis, options) {
body.unshift(b.imports([], 'svelte/internal/disclose-version'));
}
if (!custom_renderer) {
body.unshift(b.imports([], 'svelte/internal/init-operations'));
}
if (options.compatibility.componentApi === 4) {
body.unshift(b.imports([['createClassComponent', '$$_createClassComponent']], 'svelte/legacy'));
component_block.body.unshift(
@ -589,6 +601,13 @@ export function client_component(analysis, options) {
component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id')));
}
if (custom_renderer) {
component_block.body.unshift(
b.var('$$pop_renderer', b.call('$.push_renderer', b.id('$renderer')))
);
component_block.body.push(b.stmt(b.call('$$pop_renderer')));
}
if (state.events.size > 0) {
body.push(
b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name)))))

@ -2,7 +2,7 @@
/** @import { ComponentClientTransformState } from '../types.js' */
/** @import { Node } from './types.js' */
import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../../../constants.js';
import { dev, locator } from '../../../../state.js';
import { dev, locator, custom_renderer } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
/**
@ -35,7 +35,8 @@ function build_locations(nodes) {
* @param {number} [flags]
*/
export function transform_template(state, namespace, flags = 0) {
const tree = state.options.fragments === 'tree';
// custom renderers needs a tree to work because there's no template element we can use
const tree = state.options.fragments === 'tree' || custom_renderer;
const expression = tree ? state.template.as_tree() : state.template.as_html();
@ -50,7 +51,7 @@ export function transform_template(state, namespace, flags = 0) {
flags ? b.literal(flags) : undefined
);
if (state.template.contains_script_tag) {
if (state.template.contains_script_tag && !custom_renderer) {
call = b.call(`$.with_script`, call);
}

@ -137,7 +137,7 @@ function objectify(item) {
attributes.properties.push(
b.prop(
'init',
b.key(fix_attribute_casing(key)),
b.key(item.is_html ? fix_attribute_casing(key) : key),
value === undefined ? b.void0 : b.literal(value)
)
);

@ -8,7 +8,7 @@ import {
is_dom_property,
is_load_error_element
} from '../../../../../utils.js';
import { is_ignored } from '../../../../state.js';
import { is_ignored, custom_renderer } from '../../../../state.js';
import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import {
@ -38,7 +38,8 @@ import { TEMPLATE_FRAGMENT } from '../../../../../constants.js';
* @param {ComponentContext} context
*/
export function RegularElement(node, context) {
const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg';
const is_html =
context.state.metadata.namespace === 'html' && node.name !== 'svg' && !custom_renderer;
const name = is_html ? node.name.toLowerCase() : node.name;
context.state.template.push_element(name, node.start, is_html);
@ -47,7 +48,8 @@ export function RegularElement(node, context) {
return;
}
const is_custom_element = is_custom_element_node(node);
// we never treat elements as custom element in custom renderers, since we don't want to apply special handling to them (e.g. class merging)
const is_custom_element = is_custom_element_node(node) && !custom_renderer;
// cloneNode is faster, but it does not instantiate the underlying class of the
// custom element until the template is connected to the dom, which would
@ -208,7 +210,8 @@ export function RegularElement(node, context) {
/** If true, needs `__value` for inputs */
const needs_special_value_handling =
name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked');
!custom_renderer &&
(name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked'));
if (has_spread) {
build_attribute_effect(
@ -253,7 +256,7 @@ export function RegularElement(node, context) {
if (name !== 'class' || value) {
context.state.template.set_prop(attribute.name, value === true ? '' : value);
}
} else if (name === 'autofocus') {
} else if (name === 'autofocus' && !custom_renderer) {
let { value } = build_attribute_value(attribute.value, context);
context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value)));
} else if (name === 'class') {
@ -345,11 +348,27 @@ export function RegularElement(node, context) {
const empty_string = value.type === 'Literal' && value.value === '';
if (!empty_string) {
child_state.init.push(
b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value))
);
if (custom_renderer) {
// custom renderers need to use the method to invoke the renderer
context.state.template.push_text([
{
type: 'Text',
data: '',
raw: '',
start: -1,
end: -1
}
]);
const text = context.state.scope.generate('text');
context.state.init.push(b.var(text, b.call('$.child', node_id)));
context.state.init.push(b.stmt(b.call('$.set_text', b.id(text), value)));
} else {
child_state.init.push(
b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value))
);
}
}
} else if (is_customizable_select_element(node)) {
} else if (is_customizable_select_element(node) && !custom_renderer) {
// For <option>, <optgroup>, or <select> elements with rich content, we need to branch based on browser support.
// Modern browsers preserve rich HTML in options, older browsers strip it to text only.
// We create a separate template for the rich content and append it to the element.
@ -411,7 +430,7 @@ export function RegularElement(node, context) {
// The same applies if it's a `<template>` element, since we need to
// set the value of `hydrate_node` to `node.content`
if (name === 'template') {
if (name === 'template' && !custom_renderer) {
needs_reset = true;
child_state.init.push(b.stmt(b.call('$.hydrate_template', arg)));
arg = b.member(arg, 'content');
@ -448,7 +467,7 @@ export function RegularElement(node, context) {
context.state.after_update.push(...element_state.after_update);
}
if (name === 'selectedcontent') {
if (name === 'selectedcontent' && !custom_renderer) {
context.state.init.push(
b.stmt(
b.call(
@ -581,7 +600,7 @@ export function build_style_directives_object(
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
*/
function build_element_attribute_update(element, node_id, name, value, attributes) {
if (name === 'muted') {
if (name === 'muted' && !custom_renderer) {
// Special case for Firefox who needs it set as a property in order to work
return b.assignment('=', b.member(node_id, b.id('muted')), value);
}
@ -622,12 +641,12 @@ function build_element_attribute_update(element, node_id, name, value, attribute
return b.call('$.set_default_checked', node_id, value);
}
if (is_dom_property(name)) {
if (is_dom_property(name) && !custom_renderer) {
return b.assignment('=', b.member(node_id, name), value);
}
return b.call(
name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute',
name.startsWith('xlink') && !custom_renderer ? '$.set_xlink_attribute' : '$.set_attribute',
node_id,
b.literal(name),
value,

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { unwrap_optional } from '../../../../utils/ast.js';
import { custom_renderer } from '../../../../state.js';
import * as b from '#compiler/builders';
import { add_svelte_meta, build_expression, Memoizer } from './shared/utils.js';
@ -44,6 +45,12 @@ export function RenderTag(node, context) {
);
if (node.metadata.dynamic) {
// In custom renderer components, validate that the snippet is compatible
// with the current renderer before rendering it
if (custom_renderer) {
snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function);
}
// If we have a chain expression then ensure a nullish snippet function gets turned into an empty one
if (node.expression.type === 'ChainExpression') {
snippet_function = b.logical('??', snippet_function, b.id('$.noop'));
@ -57,6 +64,12 @@ export function RenderTag(node, context) {
)
);
} else {
// In custom renderer components, validate that the snippet is compatible
// with the current renderer before rendering it
if (custom_renderer) {
snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function);
}
statements.push(
add_svelte_meta(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(

@ -1,7 +1,7 @@
/** @import { AssignmentPattern, BlockStatement, Expression, Identifier, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { dev } from '../../../../state.js';
import { dev, custom_renderer } from '../../../../state.js';
import { extract_paths } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { get_value } from './shared/declarations.js';
@ -79,6 +79,12 @@ export function SnippetBlock(node, context) {
? b.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body))
: b.arrow(args, body);
// wrap snippets in components with a custom renderer so they can only be
// rendered by the same renderer that compiled them
if (custom_renderer) {
snippet = b.call('$.renderer_snippet', b.id('$renderer'), snippet);
}
const declaration = b.const(node.expression, snippet);
// Top-level snippets are hoisted so they can be referenced in the `<script>`

@ -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 } from '../../../../../state.js';
import { dev, is_ignored, custom_renderer } 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,6 +456,13 @@ 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);

@ -3,7 +3,7 @@
/** @import { ComponentContext } from '../../types' */
import { escape_html } from '../../../../../../escaping.js';
import { normalize_attribute } from '../../../../../../utils.js';
import { is_ignored } from '../../../../../state.js';
import { is_ignored, custom_renderer } from '../../../../../state.js';
import { is_event_attribute } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js';
@ -134,7 +134,7 @@ export function build_attribute_value(value, context, memoize = (value) => value
* @param {AST.Attribute} attribute
*/
export function get_attribute_name(element, attribute) {
if (!element.metadata.svg && !element.metadata.mathml) {
if (!custom_renderer && !element.metadata.svg && !element.metadata.mathml) {
return normalize_attribute(attribute.name);
}

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../../types' */
import { cannot_be_set_statically } from '../../../../../../utils.js';
import { custom_renderer } from '../../../../../state.js';
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { is_custom_element_node } from '../../../../nodes.js';
@ -83,7 +84,12 @@ export function process_children(nodes, initial, is_element, context) {
if (has_state && !within_bound_contenteditable) {
context.state.update.push(update);
} else {
context.state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value)));
if (custom_renderer) {
// custom renderers need to use the method to invoke the renderer
context.state.init.push(update);
} else {
context.state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value)));
}
}
}

@ -3,7 +3,7 @@
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
/** @import { Scope } from '../../../scope.js' */
import { is_void } from '../../../../../utils.js';
import { dev, locator } from '../../../../state.js';
import { dev, locator, custom_renderer } from '../../../../state.js';
import * as b from '#compiler/builders';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes, prepare_element_spread_object } from './shared/element.js';
@ -110,7 +110,7 @@ export function RegularElement(node, context) {
const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);
if (is_customizable_select_element(node)) {
if (is_customizable_select_element(node) && !custom_renderer) {
rest.push(b.true);
}
@ -157,7 +157,7 @@ export function RegularElement(node, context) {
const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);
if (is_customizable_select_element(node)) {
if (is_customizable_select_element(node) && !custom_renderer) {
rest.push(b.true);
}
@ -192,7 +192,11 @@ export function RegularElement(node, context) {
} else {
// For optgroup or select with rich content, add hydration marker at the start
process_children(trimmed, { ...context, state });
if ((name === 'optgroup' || name === 'select') && is_customizable_select_element(node)) {
if (
(name === 'optgroup' || name === 'select') &&
is_customizable_select_element(node) &&
!custom_renderer
) {
state.template.push(b.literal('<!>'));
}
}

@ -46,6 +46,9 @@ export let dev;
export let runes = false;
/** @type {string | null | undefined} */
export let custom_renderer = null;
/** @type {(index: number) => Location} */
export let locator;
@ -139,6 +142,7 @@ export function is_ignored(node, code) {
export function reset(state) {
dev = false;
runes = false;
custom_renderer = null;
component_name = UNKNOWN_FILENAME;
source = '';
source_lines = [];
@ -155,6 +159,7 @@ export function reset(state) {
* component_name?: string;
* rootDir?: string;
* runes: boolean;
* custom_renderer?: string | null | undefined;
* }} state
*/
export function adjust(state) {
@ -162,6 +167,7 @@ export function adjust(state) {
dev = state.dev;
runes = state.runes;
custom_renderer = state.custom_renderer;
component_name = state.component_name ?? UNKNOWN_FILENAME;
if (typeof root_dir === 'string' && filename.startsWith(root_dir)) {

@ -148,7 +148,7 @@ export interface CompileOptions extends ModuleCompileOptions {
*/
runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `globalThis.__svelte.v`.
*
* @default true
*/
@ -238,6 +238,10 @@ export interface ModuleCompileOptions {
* @since 5.36
*/
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`
*/
customRenderer?: string | ((options: { filename: string }) => string | undefined);
};
}
@ -245,6 +249,9 @@ export interface ModuleCompileOptions {
export type ValidatedModuleCompileOptions = Omit<Required<ModuleCompileOptions>, 'rootDir'> & {
rootDir: ModuleCompileOptions['rootDir'];
experimental: Required<Omit<Required<ModuleCompileOptions>['experimental'], 'customRenderer'>> & {
customRenderer: (options: { filename: string }) => string | undefined;
};
};
export type ValidatedCompileOptions = ValidatedModuleCompileOptions &

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

@ -637,7 +637,7 @@ export function import_all(as, source) {
}
/**
* @param {Array<[string, string]>} parts
* @param {Array<[string, string] | [string, string, boolean]>} parts
* @param {string} source
* @returns {ESTree.ImportDeclaration}
*/
@ -647,7 +647,7 @@ export function imports(parts, source) {
attributes: [],
source: literal(source),
specifiers: parts.map((p) => ({
type: 'ImportSpecifier',
type: p[2] ? 'ImportDefaultSpecifier' : 'ImportSpecifier',
imported: id(p[0]),
local: id(p[1])
}))

@ -44,7 +44,16 @@ const common_options = {
warningFilter: fun(() => true),
experimental: object({
async: boolean(false)
async: boolean(false),
customRenderer: parametric(
/** @type {(options: { filename: string }) => string | undefined} */ (() => undefined),
(input, keypath) => {
if (input != null && typeof input !== 'string') {
throw_error(`${keypath} should be a string, if specified`);
}
return /** @type {string | undefined} */ (input);
}
)
})
};

@ -80,3 +80,10 @@ export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;
export const DOCUMENT_FRAGMENT_NODE = 11;
export const CUSTOM_RENDERER_NODE_TYPE_MAP = /** @type {const} */ ({
fragment: DOCUMENT_FRAGMENT_NODE,
element: ELEMENT_NODE,
text: TEXT_NODE,
comment: COMMENT_NODE
});

@ -0,0 +1,59 @@
/** @import { ComponentContext } from '#client' */
/** @import { Renderer } from "./types.js" */
/** @import { Component, ComponentType, SvelteComponent } from '../../../index.js' */
import { boundary } from '../dom/blocks/boundary.js';
import { branch, effect_root } from '../reactivity/effects.js';
import { push, pop, component_context } from '../context.js';
import { push_renderer } from './state.js';
import { get_parent_node, remove_child } from '../dom/operations.js';
/**
* @template {object} [TFragment=object]
* @template {object} [TElement=object]
* @template {object} [TTextNode=object]
* @template {object} [TComment=object]
* @param {Renderer<TFragment, TElement, TTextNode, TComment>} renderer
* @returns {Renderer<TFragment, TElement, TTextNode, TComment> & { render: <Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map<any, any> } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map<any, any> }) => { component: Exports, unmount: () => void } }}
*/
export function createRenderer(renderer) {
const compound_renderer = {
...renderer,
/**
* @template {Record<string, any>} Props
* @template {Record<string, any>} Exports
* @param {ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>} Component
* @param {{} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map<any, any> } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map<any, any> }} options
*/
render(Component, { target, props, context }) {
var cleanup = push_renderer(compound_renderer);
try {
/** @type {Exports} */
// @ts-expect-error will be defined because the render effect runs synchronously
var component = undefined;
const unmount = effect_root(() => {
var anchor = compound_renderer.createComment('');
compound_renderer.insert(/** @type {*} */ (target), anchor, null);
boundary(/** @type {*} */ (anchor), { pending: () => {} }, (anchor) => {
push({});
var ctx = /** @type {ComponentContext} */ (component_context);
if (context) ctx.c = context;
branch(() => {
component = /** @type {Function} */ (Component)(anchor, props ?? {}) || {};
});
pop();
});
return () => {
var parent = get_parent_node(/** @type {*} */ (anchor));
if (parent) remove_child(parent, /** @type {*} */ (anchor));
};
});
return { component, unmount };
} finally {
cleanup();
}
}
};
return compound_renderer;
}

@ -0,0 +1,43 @@
/**
* @import { Renderer } from "./types.js";
*/
/**
* @type {Renderer<any, any, any, any> | null}
*/
export let current_renderer = null;
/**
* @param {Renderer<any, any, any, any> | null} value
*/
export function set_renderer(value) {
current_renderer = value;
}
/**
*
* @param {Renderer<any, any, any, any> | null} value
*/
export function push_renderer(value) {
let old_renderer = current_renderer;
current_renderer = value;
return () => {
current_renderer = old_renderer;
};
}
/**
* @template T
* @param {() => T} fn
* @returns {T}
*/
export function without_renderer(fn) {
if (current_renderer === null) return fn();
let previous_renderer = current_renderer;
current_renderer = null;
try {
return fn();
} finally {
current_renderer = previous_renderer;
}
}

@ -0,0 +1,88 @@
export type NodeType = 'fragment' | 'element' | 'text' | 'comment';
export type Renderer<
TFragment extends object = object,
TElement extends object = object,
TTextNode extends object = object,
TComment extends object = object,
TNode extends TFragment | TElement | TTextNode | TComment =
| TFragment
| TElement
| TTextNode
| TComment
> = {
/** Creates a fragment, a container for multiple nodes. Inserting a fragment should insert all of its children. */
createFragment(): TFragment;
/** Creates an element with the given name. */
createElement(name: string): TElement;
/** Creates a text node with the given data. */
createTextNode(data: string): TTextNode;
/**
* Creates a comment node with the given data.
* This is often used as an anchor for inserting elements; it doesn't necessarily need to be rendered.
*/
createComment(data: string): TComment;
/** Should return the type of the node in string form. */
nodeType(node: TNode): NodeType;
/**
* Return the value of the node:
* - text value of a text node
* - data value of a comment
* - null for elements and fragments
*/
getNodeValue(node: TTextNode | TComment): string | null;
/** Return the value of the attribute with the given name on the element, or null if it doesn't exist. */
getAttribute(element: TElement, name: string): string | null;
/** Set the attribute with the given name and value on the element. */
setAttribute(element: TElement, key: string, value: any): void;
/** Remove the attribute with the given name from the element. */
removeAttribute(element: TElement, name: string): void;
/** Return true if the element has an attribute with the given name. */
hasAttribute(element: TElement, name: string): boolean;
/**
* Set the text content of the node to the given value.
* This should work for both text nodes and elements.
*/
setText(node: TElement | TTextNode | TComment, text: string): void;
/** Return the first child of the element or fragment, or null if it has no children. */
getFirstChild(element: TElement | TFragment): TNode | null;
/** Return the last child of the element or fragment, or null if it has no children. */
getLastChild(element: TElement | TFragment): TNode | null;
/** Return the next sibling of the node, or null if it has no next sibling. */
getNextSibling(node: TElement | TTextNode | TComment): TNode | null;
/**
* Insert the element into the parent before the anchor.
* If anchor is null, insert at the end.
*/
insert(
parent: TElement | TFragment,
element: TNode,
anchor: TElement | TTextNode | TComment | null
): void;
/** Remove the node from the tree. */
remove(node: TElement | TTextNode | TComment): void;
/** Return the parent of the element, or null if it has no parent. */
getParent(element: TElement | TTextNode | TComment): TNode | null;
/** Add an event listener of the given type and handler to the target node. */
addEventListener(target: TElement, type: string, handler: any, options?: any): void;
/** Remove an event listener of the given type and handler from the target node. */
removeEventListener(target: TElement, type: string, handler: any, options?: any): void;
};

@ -1,3 +1,5 @@
import { remove_node } from '../dom/operations.js';
/** @type {Map<String, Set<HTMLStyleElement>>} */
var all_styles = new Map();
@ -24,7 +26,7 @@ export function cleanup_styles(hash) {
if (!styles) return;
for (const style of styles) {
style.remove();
remove_node(style);
}
all_styles.delete(hash);

@ -3,6 +3,7 @@ import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/cons
import { HYDRATION_END, HYDRATION_START } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';
import { dev_stack } from '../context.js';
import { get_first_child, get_next_sibling, get_node_value, node_type } from '../dom/operations.js';
/**
* @param {any} fn
@ -14,7 +15,11 @@ export function add_locations(fn, filename, locations) {
return (/** @type {any[]} */ ...args) => {
const dom = fn(...args);
var node = hydrating ? dom : dom.nodeType === DOCUMENT_FRAGMENT_NODE ? dom.firstChild : dom;
var node = hydrating
? dom
: node_type(dom) === DOCUMENT_FRAGMENT_NODE
? get_first_child(dom)
: dom;
assign_locations(node, filename, locations);
return dom;
@ -34,7 +39,7 @@ function assign_location(element, filename, location) {
};
if (location[2]) {
assign_locations(element.firstChild, filename, location[2]);
assign_locations(get_first_child(element), filename, location[2]);
}
}
@ -48,16 +53,17 @@ function assign_locations(node, filename, locations) {
var depth = 0;
while (node && i < locations.length) {
if (hydrating && node.nodeType === COMMENT_NODE) {
if (hydrating && node_type(node) === COMMENT_NODE) {
var comment = /** @type {Comment} */ (node);
if (comment.data[0] === HYDRATION_START) depth += 1;
else if (comment.data[0] === HYDRATION_END) depth -= 1;
const data = get_node_value(comment) ?? '';
if (data[0] === HYDRATION_START) depth += 1;
else if (data[0] === HYDRATION_END) depth -= 1;
}
if (depth === 0 && node.nodeType === ELEMENT_NODE) {
if (depth === 0 && node_type(node) === ELEMENT_NODE) {
assign_location(/** @type {Element} */ (node), filename, locations[i++]);
}
node = node.nextSibling;
node = get_next_sibling(node);
}
}

@ -1,10 +1,11 @@
import { current_renderer } from '../custom-renderer/state.js';
import * as e from '../errors.js';
/**
* @param {Node} anchor
* @param {...(()=>any)[]} args
*/
export function validate_snippet_args(anchor, ...args) {
if (typeof anchor !== 'object' || !(anchor instanceof Node)) {
if (!current_renderer && (typeof anchor !== 'object' || !(anchor instanceof Node))) {
e.invalid_snippet_arguments();
}

@ -15,6 +15,7 @@ import { is_runes } from '../../context.js';
import { Batch, current_batch, flushSync, is_flushing_sync } from '../../reactivity/batch.js';
import { BranchManager } from './branches.js';
import { capture, unset_context } from '../../reactivity/async.js';
import { get_node_value } from '../operations.js';
const PENDING = 0;
const THEN = 1;
@ -57,7 +58,8 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
// @ts-ignore coercing `node` to a `Comment` causes TypeScript and Prettier to fight
let mismatch = hydrating && is_promise(input) === (node.data === HYDRATION_START_ELSE);
let mismatch =
hydrating && is_promise(input) === (get_node_value(node) === HYDRATION_START_ELSE);
if (mismatch) {
// Hydration mismatch: remove everything inside the anchor and start fresh

@ -39,9 +39,16 @@ import { Batch, current_batch, previous_batch, schedule_effect } from '../../rea
import { internal_set, source } from '../../reactivity/sources.js';
import { tag } from '../../dev/tracing.js';
import { createSubscriber } from '../../../../reactivity/create-subscriber.js';
import { create_text } from '../operations.js';
import {
create_text,
create_fragment,
append_child,
insert_before,
get_node_value
} from '../operations.js';
import { defer_effect } from '../../reactivity/utils.js';
import { set_signal_status } from '../../reactivity/status.js';
import { push_renderer } from '../../custom-renderer/state.js';
/**
* @typedef {{
@ -164,13 +171,17 @@ export class Boundary {
const comment = /** @type {Comment} */ (this.#hydrate_open);
hydrate_next();
const server_rendered_pending = comment.data === HYDRATION_START_ELSE;
const server_rendered_failed = comment.data.startsWith(HYDRATION_START_FAILED);
const server_rendered_pending = get_node_value(comment) === HYDRATION_START_ELSE;
const server_rendered_failed = (get_node_value(comment) ?? '').startsWith(
HYDRATION_START_FAILED
);
if (server_rendered_failed) {
// Server rendered the failed snippet - hydrate it.
// The serialized error is embedded in the comment: <!--[?<json>-->
const serialized_error = JSON.parse(comment.data.slice(HYDRATION_START_FAILED.length));
const serialized_error = JSON.parse(
(get_node_value(comment) ?? '').slice(HYDRATION_START_FAILED.length)
);
this.#hydrate_failed_content(serialized_error);
} else if (server_rendered_pending) {
this.#hydrate_pending_content();
@ -219,17 +230,19 @@ export class Boundary {
this.#pending_effect = branch(() => pending(this.#anchor));
queue_micro_task(() => {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var pop_renderer = this.#effect.r !== null ? push_renderer(this.#effect.r) : null;
var fragment = (this.#offscreen_fragment = create_fragment());
var anchor = create_text();
fragment.append(anchor);
append_child(fragment, anchor);
this.#main_effect = this.#run(() => {
return branch(() => this.#children(anchor));
});
if (this.#pending_count === 0) {
this.#anchor.before(fragment);
insert_before(this.#anchor, fragment);
this.#offscreen_fragment = null;
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
@ -238,6 +251,8 @@ export class Boundary {
this.#resolve(/** @type {Batch} */ (current_batch));
}
pop_renderer?.();
});
}
@ -252,7 +267,7 @@ export class Boundary {
});
if (this.#pending_count > 0) {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var fragment = (this.#offscreen_fragment = create_fragment());
move_effect(this.#main_effect, fragment);
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
@ -309,6 +324,8 @@ export class Boundary {
set_active_reaction(this.#effect);
set_component_context(this.#effect.ctx);
var pop_renderer = this.#effect.r !== null ? push_renderer(this.#effect.r) : null;
try {
Batch.ensure();
return fn();
@ -319,6 +336,7 @@ export class Boundary {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_ctx);
pop_renderer?.();
}
}
@ -350,8 +368,10 @@ export class Boundary {
}
if (this.#offscreen_fragment) {
this.#anchor.before(this.#offscreen_fragment);
var pop_renderer = this.#effect.r !== null ? push_renderer(this.#effect.r) : null;
insert_before(this.#anchor, this.#offscreen_fragment);
this.#offscreen_fragment = null;
pop_renderer?.();
}
}
}

@ -1,4 +1,5 @@
/** @import { Effect, TemplateNode } from '#client' */
/** @import { Renderer } from '../../custom-renderer/types.js' */
import { Batch, current_batch } from '../../reactivity/batch.js';
import {
branch,
@ -9,8 +10,17 @@ import {
} from '../../reactivity/effects.js';
import { HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
import {
create_text,
should_defer_append,
create_fragment,
append_child,
insert_before,
remove_node,
get_last_child
} from '../operations.js';
import { DEV } from 'esm-env';
import { push_renderer, current_renderer } from '../../custom-renderer/state.js';
/**
* @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@ -61,6 +71,14 @@ export class BranchManager {
*/
#transition = true;
/**
* The renderer that was active when this BranchManager was created.
* Needed so that #commit can push the correct renderer when doing DOM operations
* outside of an effect context (e.g. as a batch commit callback).
* @type {Renderer | null}
*/
#renderer = null;
/**
* @param {TemplateNode} anchor
* @param {boolean} transition
@ -68,6 +86,7 @@ export class BranchManager {
constructor(anchor, transition = true) {
this.anchor = anchor;
this.#transition = transition;
this.#renderer = current_renderer;
}
/**
@ -77,6 +96,8 @@ export class BranchManager {
// if this batch was made obsolete, bail
if (!this.#batches.has(batch)) return;
var pop_renderer = this.#renderer !== null ? push_renderer(this.#renderer) : null;
var key = /** @type {Key} */ (this.#batches.get(batch));
var onscreen = this.#onscreen.get(key);
@ -96,14 +117,14 @@ export class BranchManager {
if (DEV) {
// Tell hmr.js about the anchor it should use for updates,
// since the initial one will be removed
/** @type {any} */ (offscreen.fragment.lastChild)[HMR_ANCHOR] = this.anchor;
/** @type {any} */ (get_last_child(offscreen.fragment))[HMR_ANCHOR] = this.anchor;
}
// remove the anchor...
/** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove();
remove_node(/** @type {ChildNode} */ (get_last_child(offscreen.fragment)));
// ...and append the fragment
this.anchor.before(offscreen.fragment);
insert_before(this.anchor, offscreen.fragment);
onscreen = offscreen.effect;
}
}
@ -137,10 +158,10 @@ export class BranchManager {
if (keys.includes(k)) {
// keep the effect offscreen, as another batch will need it
var fragment = document.createDocumentFragment();
var fragment = create_fragment();
move_effect(effect, fragment);
fragment.append(create_text()); // TODO can we avoid this?
append_child(fragment, create_text()); // TODO can we avoid this?
this.#offscreen.set(k, { effect, fragment });
} else {
@ -158,6 +179,8 @@ export class BranchManager {
on_destroy();
}
}
pop_renderer?.();
};
/**
@ -187,10 +210,10 @@ export class BranchManager {
if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) {
if (defer) {
var fragment = document.createDocumentFragment();
var fragment = create_fragment();
var target = create_text();
fragment.append(target);
append_child(fragment, target);
this.#offscreen.set(key, {
effect: branch(() => fn(target)),

@ -1,6 +1,6 @@
import { render_effect } from '../../reactivity/effects.js';
import { hydrating, set_hydrate_node } from '../hydration.js';
import { get_first_child } from '../operations.js';
import { get_first_child, style_set_property, style_remove_property } from '../operations.js';
/**
* @param {HTMLDivElement | SVGGElement} element
@ -19,9 +19,9 @@ export function css_props(element, get_styles) {
var value = styles[key];
if (value) {
element.style.setProperty(key, value);
style_set_property(/** @type {HTMLElement} */ (element), key, value);
} else {
element.style.removeProperty(key);
style_remove_property(/** @type {HTMLElement} */ (element), key);
}
}
});

@ -23,7 +23,13 @@ import {
create_text,
get_first_child,
get_next_sibling,
should_defer_append
should_defer_append,
get_parent_node,
append_child,
create_fragment,
insert_before,
node_type,
get_node_value
} from '../operations.js';
import {
block,
@ -44,6 +50,8 @@ import { current_batch } from '../../reactivity/batch.js';
import * as e from '../../errors.js';
import { tag } from '../../dev/tracing.js';
import { push_renderer, current_renderer } from '../../custom-renderer/state.js';
// When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
// This test also exists in this repo, as `packages/svelte/tests/manual/each-stress-test`
@ -108,10 +116,10 @@ function pause_effects(state, to_destroy, controlled_anchor) {
if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor);
var parent_node = /** @type {Element} */ (anchor.parentNode);
var parent_node = /** @type {Element} */ (get_parent_node(anchor));
clear_text_content(parent_node);
parent_node.append(anchor);
append_child(parent_node, anchor);
state.items.clear();
}
@ -153,7 +161,7 @@ function destroy_effects(state, to_destroy, remove_dom = true) {
if (preserved_effects?.has(e)) {
e.f |= EFFECT_OFFSCREEN;
const fragment = document.createDocumentFragment();
const fragment = create_fragment();
move_effect(e, fragment);
} else {
destroy_effect(to_destroy[i], remove_dom);
@ -161,8 +169,34 @@ function destroy_effects(state, to_destroy, remove_dom = true) {
}
}
/** @type {TemplateNode} */
var offscreen_anchor;
// we need to handle the case of multiple renderers so we need to use a WeakMap of offscreen anchors rather than a single variable.
// we also need the dom_weak_key since `null` is not a viable WeakKey
/** @type {WeakMap<WeakKey, TemplateNode>} */
var offscreen_anchors = new WeakMap();
var dom_weak_key = {};
/**
* Returns an anchor node suitable for offscreen rendering.
* When a custom renderer is active, the anchor must have a parent
* (so that `getParent()` works), so we place the text node inside
* a fragment. The result is cached just like the non-renderer path.
* @returns {TemplateNode}
*/
function get_offscreen_anchor() {
// not doing it inline to please typescript
var cached = offscreen_anchors.get(current_renderer ?? dom_weak_key);
if (cached) return cached;
var offscreen_anchor = create_text();
if (current_renderer) {
var fragment = create_fragment();
append_child(fragment, offscreen_anchor);
}
offscreen_anchors.set(current_renderer ?? dom_weak_key, offscreen_anchor);
return offscreen_anchor;
}
/**
* @template V
@ -182,12 +216,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0;
// Capture the renderer that was active when this each block was created.
// Needed so that the commit callback can push the correct renderer when doing
// DOM operations outside of an effect context (e.g. as a batch commit callback).
var renderer = current_renderer;
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
anchor = hydrating
? set_hydrate_node(get_first_child(parent_node))
: parent_node.appendChild(create_text());
: /** @type {Text} */ (append_child(parent_node, create_text()));
}
if (hydrating) {
@ -226,6 +265,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
return;
}
var pop_renderer = renderer !== null ? push_renderer(renderer) : null;
state.pending.delete(batch);
state.fallback = fallback;
@ -248,6 +289,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
});
}
}
pop_renderer?.();
}
/**
@ -284,8 +327,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
for (var index = 0; index < length; index += 1) {
if (
hydrating &&
hydrate_node.nodeType === COMMENT_NODE &&
/** @type {Comment} */ (hydrate_node).data === HYDRATION_END
node_type(hydrate_node) === COMMENT_NODE &&
get_node_value(hydrate_node) === HYDRATION_END
) {
// The server rendered fewer items than expected,
// so break out and continue appending non-hydrated items
@ -318,7 +361,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
} else {
item = create_item(
items,
first_run ? anchor : (offscreen_anchor ??= create_text()),
first_run ? anchor : get_offscreen_anchor(),
value,
key,
index,
@ -341,7 +384,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (first_run) {
fallback = branch(() => fallback_fn(anchor));
} else {
fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));
fallback = branch(() => fallback_fn(get_offscreen_anchor()));
fallback.f |= EFFECT_OFFSCREEN;
}
}
@ -713,7 +756,7 @@ function move(effect, next, anchor) {
while (node !== null) {
var next_node = /** @type {TemplateNode} */ (get_next_sibling(node));
dest.before(node);
insert_before(dest, node);
if (node === end) {
return;

@ -14,7 +14,17 @@ import * as w from '../../warnings.js';
import { hash, sanitize_location } from '../../../../utils.js';
import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../context.js';
import { create_element, get_first_child, get_next_sibling } from '../operations.js';
import {
create_element,
get_first_child,
get_next_sibling,
get_last_child,
set_inner_html,
insert_before,
get_parent_node,
node_type,
get_node_value
} from '../operations.js';
import { active_effect } from '../../runtime.js';
import { COMMENT_NODE } from '#client/constants';
@ -81,12 +91,12 @@ export function html(
// When @html is the only child, use innerHTML directly.
// This also handles contenteditable, where the user may delete the anchor comment.
effect.nodes = null;
parent_node.innerHTML = /** @type {string} */ (value);
set_inner_html(parent_node, /** @type {string} */ (value));
if (value !== '') {
assign_nodes(
/** @type {TemplateNode} */ (get_first_child(parent_node)),
/** @type {TemplateNode} */ (parent_node.lastChild)
/** @type {TemplateNode} */ (get_last_child(parent_node))
);
}
@ -103,16 +113,13 @@ export function html(
if (hydrating) {
// We're deliberately not trying to repair mismatches between server and client,
// as it's costly and error-prone (and it's an edge case to have a mismatch anyway)
var hash = /** @type {Comment} */ (hydrate_node).data;
var hash = get_node_value(hydrate_node);
/** @type {TemplateNode | null} */
var next = hydrate_next();
var last = next;
while (
next !== null &&
(next.nodeType !== COMMENT_NODE || /** @type {Comment} */ (next).data !== '')
) {
while (next !== null && (node_type(next) !== COMMENT_NODE || get_node_value(next) !== '')) {
last = next;
next = get_next_sibling(next);
}
@ -123,7 +130,7 @@ export function html(
}
if (DEV && !skip_warning) {
check_hash(/** @type {Element} */ (next.parentNode), hash, value);
check_hash(/** @type {Element} */ (get_parent_node(next)), hash, value);
}
assign_nodes(hydrate_node, last);
@ -139,22 +146,22 @@ export function html(
var wrapper = /** @type {HTMLTemplateElement | SVGElement | MathMLElement} */ (
create_element(svg ? 'svg' : mathml ? 'math' : 'template', ns)
);
wrapper.innerHTML = /** @type {any} */ (value);
set_inner_html(wrapper, /** @type {any} */ (value));
/** @type {DocumentFragment | Element} */
var node = svg || mathml ? wrapper : /** @type {HTMLTemplateElement} */ (wrapper).content;
assign_nodes(
/** @type {TemplateNode} */ (get_first_child(node)),
/** @type {TemplateNode} */ (node.lastChild)
/** @type {TemplateNode} */ (get_last_child(node))
);
if (svg || mathml) {
while (get_first_child(node)) {
anchor.before(/** @type {TemplateNode} */ (get_first_child(node)));
insert_before(anchor, /** @type {TemplateNode} */ (get_first_child(node)));
}
} else {
anchor.before(node);
insert_before(anchor, node);
}
});
}

@ -13,9 +13,10 @@ import { assign_nodes } from '../template.js';
import * as w from '../../warnings.js';
import * as e from '../../errors.js';
import { DEV } from 'esm-env';
import { get_first_child, get_next_sibling } from '../operations.js';
import { get_first_child, get_next_sibling, insert_before, node_type } from '../operations.js';
import { prevent_snippet_stringification } from '../../../shared/validate.js';
import { BranchManager } from './branches.js';
import { current_renderer } from '../../custom-renderer/state.js';
/**
* @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
@ -61,6 +62,48 @@ export function wrap_snippet(component, fn) {
return snippet;
}
/**
* Wraps a snippet function created in a component with a custom renderer,
* ensuring it can only be rendered by the same renderer.
* @template {(...args: any[]) => void} T
* @param {any} expected_renderer
* @param {T} fn
* @returns {T}
*/
export function renderer_snippet(expected_renderer, fn) {
var wrapped = /** @type {T} */ (
(.../** @type {any[]} */ args) => {
if (current_renderer !== expected_renderer) {
e.snippet_renderer_mismatch();
}
return fn(...args);
}
);
// we could technically avoid checking for expected_renderer in the function
// and store it in the returned function to check with `validate_snippet_renderer`
// but this keeps all the changes on the custom renderer side and leave the paths
// of "normal svelte" untouched...since that's the default people are gonna use
// svelte with we should optimize for that case
/** @type {any} */ (wrapped).__renderer = expected_renderer;
return wrapped;
}
/**
* Validates that a snippet function is compatible with the given renderer.
* Used at render sites in custom renderer components.
* @template {((...args: any[]) => void) | null | undefined} T
* @param {any} expected_renderer
* @param {T} fn
* @returns {T}
*/
export function validate_snippet_renderer(expected_renderer, fn) {
if (fn != null && /** @type {any} */ (fn).__renderer !== expected_renderer) {
e.snippet_renderer_mismatch();
}
return fn;
}
/**
* Create a snippet programmatically
* @template {unknown[]} Params
@ -73,6 +116,9 @@ export function wrap_snippet(component, fn) {
export function createRawSnippet(fn) {
// @ts-expect-error the types are a lie
return (/** @type {TemplateNode} */ anchor, /** @type {Getters<Params>} */ ...params) => {
if (current_renderer != null) {
e.invalid_snippet_in_custom_renderer();
}
var snippet = fn(...params);
/** @type {Element} */
@ -86,11 +132,11 @@ export function createRawSnippet(fn) {
var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (get_first_child(fragment));
if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) {
if (DEV && (get_next_sibling(element) !== null || node_type(element) !== ELEMENT_NODE)) {
w.invalid_raw_snippet_render();
}
anchor.before(element);
insert_before(anchor, element);
}
const result = snippet.setup?.(element);

@ -7,7 +7,15 @@ import {
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import { create_element, create_text, get_first_child } from '../operations.js';
import {
create_element,
create_text,
get_first_child,
append_child,
create_comment,
insert_before,
node_type
} from '../operations.js';
import { block, teardown } from '../../reactivity/effects.js';
import { set_should_intro } from '../../render.js';
import { active_effect } from '../../runtime.js';
@ -40,7 +48,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
/** @type {null | Element} */
var element = null;
if (hydrating && hydrate_node.nodeType === ELEMENT_NODE) {
if (hydrating && node_type(hydrate_node) === ELEMENT_NODE) {
element = /** @type {Element} */ (hydrate_node);
hydrate_next();
}
@ -90,14 +98,14 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
if (render_fn) {
if (hydrating && is_raw_text_element(next_tag)) {
// prevent hydration glitches
element.append(document.createComment(''));
append_child(element, create_comment(''));
}
// If hydrating, use the existing ssr comment as the anchor so that the
// inner open and close methods can pick up the existing nodes correctly
var child_anchor = hydrating
? get_first_child(element)
: element.appendChild(create_text());
: /** @type {Text} */ (append_child(element, create_text()));
if (hydrating) {
if (child_anchor === null) {
@ -121,7 +129,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
// we do this after calling `render_fn` so that child effects don't override `nodes.end`
/** @type {Effect & { nodes: EffectNodes }} */ (active_effect).nodes.end = element;
anchor.before(element);
insert_before(anchor, element);
}
if (hydrating) {

@ -1,6 +1,14 @@
/** @import { TemplateNode } from '#client' */
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_text, get_first_child, get_next_sibling } from '../operations.js';
import {
create_text,
get_first_child,
get_next_sibling,
remove_node,
append_child,
node_type,
get_node_value
} from '../operations.js';
import { block } from '../../reactivity/effects.js';
import { COMMENT_NODE, EFFECT_PRESERVED, HEAD_EFFECT } from '#client/constants';
@ -27,7 +35,7 @@ export function head(hash, render_fn) {
// rendered in an arbitrary order — find one corresponding to this component
while (
head_anchor !== null &&
(head_anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (head_anchor).data !== hash)
(node_type(head_anchor) !== COMMENT_NODE || get_node_value(head_anchor) !== hash)
) {
head_anchor = get_next_sibling(head_anchor);
}
@ -38,14 +46,14 @@ export function head(hash, render_fn) {
set_hydrating(false);
} else {
var start = /** @type {TemplateNode} */ (get_next_sibling(head_anchor));
head_anchor.remove(); // in case this component is repeated
remove_node(/** @type {ChildNode} */ (head_anchor)); // in case this component is repeated
set_hydrate_node(start);
}
}
if (!hydrating) {
anchor = document.head.appendChild(create_text());
anchor = /** @type {Comment | Text} */ (append_child(document.head, create_text()));
}
try {

@ -1,7 +1,7 @@
import { DEV } from 'esm-env';
import { register_style } from '../dev/css.js';
import { effect } from '../reactivity/effects.js';
import { create_element } from './operations.js';
import { create_element, append_child, set_text_content } from './operations.js';
/**
* @param {Node} anchor
@ -18,12 +18,12 @@ export function append_styles(anchor, css) {
// Always querying the DOM is roughly the same perf as additionally checking for presence in a map first assuming
// that you'll get cache hits half of the time, so we just always query the dom for simplicity and code savings.
if (!target.querySelector('#' + css.hash)) {
if (!(/** @type {Element} */ (target).querySelector('#' + css.hash))) {
const style = create_element('style');
style.id = css.hash;
style.textContent = css.code;
set_text_content(style, css.code);
target.appendChild(style);
append_child(target, style);
if (DEV) {
register_style(css.hash, style);

@ -23,6 +23,19 @@ import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../const
import { branch, destroy_effect, effect, managed } from '../../reactivity/effects.js';
import { init_select, select_option } from './bindings/select.js';
import { flatten } from '../../reactivity/async.js';
import {
has_attribute,
get_attribute,
remove_attribute,
set_attribute as set_attribute_op,
remove_event_listener,
node_name,
set_element_value,
set_element_checked,
set_element_default_value,
set_element_default_checked
} from '../operations.js';
import { current_renderer } from '../../custom-renderer/state.js';
export const CLASS = Symbol('class');
export const STYLE = Symbol('style');
@ -45,6 +58,8 @@ const PROGRESS_TAG = IS_XHTML ? 'progress' : 'PROGRESS';
export function remove_input_defaults(input) {
if (!hydrating) return;
// we should be safe here from custom renderers as this only run during hydration
var already_removed = false;
// We try and remove the default attributes later, rather than sync during hydration.
@ -56,13 +71,13 @@ export function remove_input_defaults(input) {
already_removed = true;
// Remove the attributes but preserve the values
if (input.hasAttribute('value')) {
if (has_attribute(input, 'value')) {
var value = input.value;
set_attribute(input, 'value', null);
input.value = value;
}
if (input.hasAttribute('checked')) {
if (has_attribute(input, 'checked')) {
var checked = input.checked;
set_attribute(input, 'checked', null);
input.checked = checked;
@ -89,13 +104,12 @@ export function set_value(element, value) {
value ?? undefined) ||
// @ts-expect-error
// `progress` elements always need their value set when it's `0`
(element.value === value && (value !== 0 || element.nodeName !== PROGRESS_TAG))
(element.value === value && (value !== 0 || node_name(element) !== PROGRESS_TAG))
) {
return;
}
// @ts-expect-error
element.value = value ?? '';
set_element_value(element, value);
}
/**
@ -114,8 +128,7 @@ export function set_checked(element, checked) {
return;
}
// @ts-expect-error
element.checked = checked;
set_element_checked(element, checked);
}
/**
@ -129,11 +142,11 @@ export function set_selected(element, selected) {
if (selected) {
// The selected option could've changed via user selection, and
// setting the value without this check would set it back.
if (!element.hasAttribute('selected')) {
element.setAttribute('selected', '');
if (!has_attribute(element, 'selected')) {
set_attribute_op(element, 'selected', '');
}
} else {
element.removeAttribute('selected');
remove_attribute(element, 'selected');
}
}
@ -143,9 +156,7 @@ export function set_selected(element, selected) {
* @param {boolean} checked
*/
export function set_default_checked(element, checked) {
const existing_value = element.checked;
element.defaultChecked = checked;
element.checked = existing_value;
set_element_default_checked(element, checked);
}
/**
@ -154,9 +165,7 @@ export function set_default_checked(element, checked) {
* @param {string} value
*/
export function set_default_value(element, value) {
const existing_value = element.value;
element.defaultValue = value;
element.value = existing_value;
set_element_default_value(element, value);
}
/**
@ -169,12 +178,12 @@ export function set_attribute(element, attribute, value, skip_warning) {
var attributes = get_attributes(element);
if (hydrating) {
attributes[attribute] = element.getAttribute(attribute);
attributes[attribute] = get_attribute(element, attribute);
if (
attribute === 'src' ||
attribute === 'srcset' ||
(attribute === 'href' && element.nodeName === LINK_TAG)
(attribute === 'href' && node_name(element) === LINK_TAG)
) {
if (!skip_warning) {
check_src_in_dev_hydration(element, attribute, value ?? '');
@ -196,12 +205,12 @@ export function set_attribute(element, attribute, value, skip_warning) {
}
if (value == null) {
element.removeAttribute(attribute);
remove_attribute(element, attribute);
} else if (typeof value !== 'string' && get_setters(element).includes(attribute)) {
// @ts-ignore
element[attribute] = value;
} else {
element.setAttribute(attribute, value);
set_attribute_op(element, attribute, value);
}
}
@ -211,6 +220,7 @@ export function set_attribute(element, attribute, value, skip_warning) {
* @param {string} value
*/
export function set_xlink_attribute(dom, attribute, value) {
// custom renderer safe since we never emit this
dom.setAttributeNS('http://www.w3.org/1999/xlink', attribute, value);
}
@ -220,6 +230,7 @@ export function set_xlink_attribute(dom, attribute, value) {
* @param {any} value
*/
export function set_custom_element_data(node, prop, value) {
// custom renderer safe since we never emit this
// We need to ensure that setting custom element props, which can
// invoke lifecycle methods on other custom elements, does not also
// associate those lifecycle methods with the current active reaction
@ -244,10 +255,10 @@ export function set_custom_element_data(node, prop, value) {
// Don't compute setters for custom elements while they aren't registered yet,
// because during their upgrade/instantiation they might add more setters.
// Instead, fall back to a simple "an object, then set as property" heuristic.
(setters_cache.has(node.getAttribute('is') || node.nodeName) ||
(setters_cache.has(get_attribute(node, 'is') || (node_name(node) ?? '')) ||
// customElements may not be available in browser extension contexts
!customElements ||
customElements.get(node.getAttribute('is') || node.nodeName.toLowerCase())
customElements.get(get_attribute(node, 'is') || (node_name(node) ?? '').toLowerCase())
? get_setters(node).includes(prop)
: value && typeof value === 'object')
) {
@ -286,7 +297,7 @@ function set_attributes(
should_remove_defaults = false,
skip_warning = false
) {
if (hydrating && should_remove_defaults && element.nodeName === INPUT_TAG) {
if (hydrating && should_remove_defaults && node_name(element) === INPUT_TAG) {
var input = /** @type {HTMLInputElement} */ (element);
var attribute = input.type === 'checkbox' ? 'defaultChecked' : 'defaultValue';
@ -308,7 +319,7 @@ function set_attributes(
}
var current = prev || {};
var is_option_element = element.nodeName === OPTION_TAG;
var is_option_element = node_name(element) === OPTION_TAG;
for (var key in prev) {
if (!(key in next)) {
@ -353,7 +364,8 @@ function set_attributes(
}
if (key === 'class') {
var is_html = element.namespaceURI === 'http://www.w3.org/1999/xhtml';
var is_html =
element.namespaceURI === 'http://www.w3.org/1999/xhtml' && current_renderer != null;
set_class(element, is_html, value, css_hash, prev?.[CLASS], next[CLASS]);
current[key] = value;
current[CLASS] = next[CLASS];
@ -370,7 +382,7 @@ function set_attributes(
var prev_value = current[key];
// Skip if value is unchanged, unless it's `undefined` and the element still has the attribute
if (value === prev_value && !(value === undefined && element.hasAttribute(key))) {
if (value === prev_value && !(value === undefined && has_attribute(element, key))) {
continue;
}
@ -384,7 +396,7 @@ function set_attributes(
const opts = {};
const event_handle_key = '$$' + key;
let event_name = key.slice(2);
var is_delegated = can_delegate_event(event_name);
var is_delegated = current_renderer == null && can_delegate_event(event_name);
if (is_capture_event(event_name)) {
event_name = event_name.slice(0, -7);
@ -398,7 +410,7 @@ function set_attributes(
// https://github.com/sveltejs/svelte/issues/11903
if (value != null) continue;
element.removeEventListener(event_name, current[event_handle_key], opts);
remove_event_listener(element, event_name, current[event_handle_key], opts);
current[event_handle_key] = null;
}
@ -408,10 +420,10 @@ function set_attributes(
} else if (value != null) {
/**
* @this {any}
* @param {Event} evt
* @param {...any} args
*/
function handle(evt) {
current[key].call(this, evt);
function handle(...args) {
current[key].apply(this, args);
}
current[event_handle_key] = create_event(event_name, element, handle, opts);
@ -420,11 +432,21 @@ function set_attributes(
// avoid using the setter
set_attribute(element, key, value);
} else if (key === 'autofocus') {
autofocus(/** @type {HTMLElement} */ (element), Boolean(value));
if (current_renderer == null) {
autofocus(/** @type {HTMLElement} */ (element), Boolean(value));
} else {
// In custom renderer mode, just set autofocus as a regular attribute
if (value) {
set_attribute_op(element, key, value);
} else {
remove_attribute(element, key);
}
}
} else if (!is_custom_element && (key === '__value' || (key === 'value' && value != null))) {
// @ts-ignore We're not running this for custom elements because __value is actually
// how Lit stores the current value on the element, and messing with that would break things.
element.value = element.__value = value;
element.__value = value;
set_element_value(element, value);
} else if (key === 'selected' && is_option_element) {
set_selected(/** @type {HTMLOptionElement} */ (element), value);
} else {
@ -437,26 +459,39 @@ function set_attributes(
if (value == null && !is_custom_element && !is_default) {
attributes[key] = null;
if (name === 'value' || name === 'checked') {
if ((name === 'value' || name === 'checked') && current_renderer == null) {
// removing value/checked also removes defaultValue/defaultChecked — preserve
let input = /** @type {HTMLInputElement} */ (element);
const use_default = prev === undefined;
if (name === 'value') {
let previous = input.defaultValue;
input.removeAttribute(name);
input.defaultValue = previous;
remove_attribute(input, name);
set_element_default_value(input, previous);
// @ts-ignore
input.value = input.__value = use_default ? previous : null;
set_element_value(input, (input.__value = use_default ? previous : null));
} else {
let previous = input.defaultChecked;
input.removeAttribute(name);
input.defaultChecked = previous;
input.checked = use_default ? previous : false;
remove_attribute(input, name);
set_element_default_checked(input, previous);
set_element_checked(input, use_default ? previous : false);
}
} else {
element.removeAttribute(key);
remove_attribute(element, key);
if (name === 'value') {
// @ts-ignore
element.__value = null;
}
}
} else if (is_default && current_renderer != null) {
// Route through the renderer-aware abstraction so custom renderers
// see defaultValue/defaultChecked as proper attributes
if (name === 'defaultValue') {
set_element_default_value(element, value);
} else {
set_element_default_checked(element, value);
}
// remove it from attributes's cache
if (name in attributes) attributes[name] = UNINITIALIZED;
} else if (
is_default ||
(setters.includes(name) && (is_custom_element || typeof value !== 'string'))
@ -505,7 +540,7 @@ export function attribute_effect(
/** @type {Record<symbol, Effect>} */
var effects = {};
var is_select = element.nodeName === SELECT_TAG;
var is_select = node_name(element) === SELECT_TAG;
var inited = false;
managed(() => {
@ -563,7 +598,7 @@ function get_attributes(element) {
return /** @type {Record<string | symbol, unknown>} **/ (
// @ts-expect-error
element.__attributes ??= {
[IS_CUSTOM_ELEMENT]: element.nodeName.includes('-'),
[IS_CUSTOM_ELEMENT]: (node_name(element) ?? '').includes('-'),
[IS_HTML]: element.namespaceURI === NAMESPACE_HTML
}
);
@ -574,7 +609,9 @@ var setters_cache = new Map();
/** @param {Element} element */
function get_setters(element) {
var cache_key = element.getAttribute('is') || element.nodeName;
// if we have a custom renderer we just skip the check all together
if (current_renderer) return [];
var cache_key = get_attribute(element, 'is') || (node_name(element) ?? '');
var setters = setters_cache.get(cache_key);
if (setters) return setters;
setters_cache.set(cache_key, (setters = []));
@ -606,9 +643,9 @@ function get_setters(element) {
* @param {string} value
*/
function check_src_in_dev_hydration(element, attribute, value) {
if (!DEV) return;
if (!DEV || current_renderer != null) return;
if (attribute === 'srcset' && srcset_url_equal(element, value)) return;
if (src_url_equal(element.getAttribute(attribute) ?? '', value)) return;
if (src_url_equal(get_attribute(element, attribute) ?? '', value)) return;
w.hydration_attribute_changed(
attribute,
@ -623,7 +660,7 @@ function check_src_in_dev_hydration(element, attribute, value) {
* @returns {boolean}
*/
function src_url_equal(element_src, url) {
if (element_src === url) return true;
if (element_src === url || current_renderer != null) return true;
return new URL(element_src, document.baseURI).href === new URL(url, document.baseURI).href;
}
@ -638,6 +675,7 @@ function split_srcset(srcset) {
* @returns {boolean}
*/
function srcset_url_equal(element, srcset) {
if (current_renderer != null) return true;
var element_urls = split_srcset(element.srcset);
var urls = split_srcset(srcset);

@ -1,5 +1,11 @@
import { to_class } from '../../../shared/attributes.js';
import { hydrating } from '../hydration.js';
import {
class_list_toggle,
get_attribute,
remove_attribute,
set_attribute
} from '../operations.js';
/**
* @param {Element} dom
@ -21,17 +27,17 @@ export function set_class(dom, is_html, value, hash, prev_classes, next_classes)
) {
var next_class_name = to_class(value, hash, next_classes);
if (!hydrating || next_class_name !== dom.getAttribute('class')) {
if (!hydrating || next_class_name !== get_attribute(dom, 'class')) {
// Removing the attribute when the value is only an empty string causes
// performance issues vs simply making the className an empty string. So
// we should only remove the class if the value is nullish
// and there no hash/directives :
if (next_class_name == null) {
dom.removeAttribute('class');
remove_attribute(dom, 'class');
} else if (is_html) {
dom.className = next_class_name;
} else {
dom.setAttribute('class', next_class_name);
set_attribute(dom, 'class', next_class_name);
}
}
@ -42,7 +48,7 @@ export function set_class(dom, is_html, value, hash, prev_classes, next_classes)
var is_present = !!next_classes[key];
if (prev_classes == null || is_present !== !!prev_classes[key]) {
dom.classList.toggle(key, is_present);
class_list_toggle(/** @type {HTMLElement} */ (dom), key, is_present);
}
}
}

@ -11,6 +11,14 @@ import {
set_active_reaction
} from '../../runtime.js';
import { without_reactive_context } from './bindings/shared.js';
import {
remove_attribute,
dispatch_event,
add_event_listener,
remove_event_listener,
get_parent_node
} from '../operations.js';
import { current_renderer } from '../../custom-renderer/state.js';
/**
* Used on elements, as a map of event type -> event handler,
@ -30,10 +38,11 @@ export const root_event_handles = new Set();
* @param {HTMLElement} dom
*/
export function replay_events(dom) {
// custom renderers safe since it immediately returns if not hydrating
if (!hydrating) return;
dom.removeAttribute('onload');
dom.removeAttribute('onerror');
remove_attribute(dom, 'onload');
remove_attribute(dom, 'onerror');
// @ts-expect-error
const event = dom.__e;
if (event !== undefined) {
@ -41,7 +50,7 @@ export function replay_events(dom) {
dom.__e = undefined;
queueMicrotask(() => {
if (dom.isConnected) {
dom.dispatchEvent(event);
dispatch_event(dom, event);
}
});
}
@ -54,10 +63,23 @@ export function replay_events(dom) {
* @param {AddEventListenerOptions} [options]
*/
export function create_event(event_name, dom, handler, options = {}) {
// Capture whether a custom renderer is active at creation time (during mount),
// since `renderer` will be null when the event actually fires
var is_custom_renderer = current_renderer != null;
/**
* @this {EventTarget}
* @param {...any} args
*/
function target_handler(/** @type {Event} */ event) {
function target_handler(...args) {
if (is_custom_renderer) {
// Custom renderers don't use DOM event propagation/delegation,
// so just call the handler directly
return without_reactive_context(() => {
return handler?.apply(this, /** @type {any} */ (args));
});
}
var event = /** @type {Event} */ (args[0]);
if (!options.capture) {
// Only call in the bubble phase, else delegated events would be called before the capturing events
handle_event_propagation.call(dom, event);
@ -74,15 +96,14 @@ export function create_event(event_name, dom, handler, options = {}) {
// defer the attachment till after it's been appended to the document. TODO: remove this once Chrome fixes
// this bug. The same applies to wheel events and touch events.
if (
event_name.startsWith('pointer') ||
event_name.startsWith('touch') ||
event_name === 'wheel'
!is_custom_renderer &&
(event_name.startsWith('pointer') || event_name.startsWith('touch') || event_name === 'wheel')
) {
queue_micro_task(() => {
dom.addEventListener(event_name, target_handler, options);
add_event_listener(dom, event_name, target_handler, options);
});
} else {
dom.addEventListener(event_name, target_handler, options);
add_event_listener(dom, event_name, target_handler, options);
}
return target_handler;
@ -102,7 +123,7 @@ export function on(element, type, handler, options = {}) {
var target_handler = create_event(type, element, handler, options);
return () => {
element.removeEventListener(type, target_handler, options);
remove_event_listener(element, type, target_handler, options);
};
}
@ -119,16 +140,18 @@ export function event(event_name, dom, handler, capture, passive) {
var target_handler = create_event(event_name, dom, handler, options);
if (
dom === document.body ||
// @ts-ignore
dom === window ||
// @ts-ignore
dom === document ||
// Firefox has quirky behavior, it can happen that we still get "canplay" events when the element is already removed
dom instanceof HTMLMediaElement
// if there's a renderer we will never add to body, window or document.
current_renderer == null &&
(dom === document.body ||
// @ts-ignore
dom === window ||
// @ts-ignore
dom === document ||
// Firefox has quirky behavior, it can happen that we still get "canplay" events when the element is already removed
dom instanceof HTMLMediaElement)
) {
teardown(() => {
dom.removeEventListener(event_name, target_handler, options);
remove_event_listener(dom, event_name, target_handler, options);
});
}
}
@ -171,6 +194,7 @@ let last_propagated_event = null;
* @returns {void}
*/
export function handle_event_propagation(event) {
// this function is custom renderers safe since it's invoked in `mount`
var handler_element = this;
var owner_document = /** @type {Node} */ (handler_element).ownerDocument;
var event_name = event.type;
@ -260,7 +284,7 @@ export function handle_event_propagation(event) {
/** @type {null | Element} */
var parent_element =
current_target.assignedSlot ||
current_target.parentNode ||
get_parent_node(current_target) ||
/** @type {any} */ (current_target).host ||
null;

@ -1,5 +1,5 @@
import { hydrating } from '../hydration.js';
import { clear_text_content, get_first_child } from '../operations.js';
import { clear_text_content, get_first_child, add_event_listener } from '../operations.js';
import { queue_micro_task } from '../task.js';
/**
@ -37,7 +37,8 @@ let listening_to_form_reset = false;
export function add_form_reset_listener() {
if (!listening_to_form_reset) {
listening_to_form_reset = true;
document.addEventListener(
add_event_listener(
document,
'reset',
(evt) => {
// Needs to happen one tick later or else the dom properties of the form

@ -1,5 +1,12 @@
import { to_style } from '../../../shared/attributes.js';
import { hydrating } from '../hydration.js';
import {
style_remove_property,
style_set_property,
get_attribute,
remove_attribute,
set_css_text
} from '../operations.js';
/**
* @param {Element & ElementCSSInlineStyle} dom
@ -13,9 +20,9 @@ function update_styles(dom, prev = {}, next, priority) {
if (prev[key] !== value) {
if (next[key] == null) {
dom.style.removeProperty(key);
style_remove_property(/** @type {HTMLElement} */ (dom), key);
} else {
dom.style.setProperty(key, value, priority);
style_set_property(/** @type {HTMLElement} */ (dom), key, value, priority);
}
}
}
@ -34,11 +41,11 @@ export function set_style(dom, value, prev_styles, next_styles) {
if (hydrating || prev !== value) {
var next_style_attr = to_style(value, next_styles);
if (!hydrating || next_style_attr !== dom.getAttribute('style')) {
if (!hydrating || next_style_attr !== get_attribute(dom, 'style')) {
if (next_style_attr == null) {
dom.removeAttribute('style');
remove_attribute(dom, 'style');
} else {
dom.style.cssText = next_style_attr;
set_css_text(/** @type {HTMLElement} */ (dom), next_style_attr);
}
}

@ -8,7 +8,7 @@ import {
HYDRATION_START_ELSE
} from '../../../constants.js';
import * as w from '../warnings.js';
import { get_next_sibling } from './operations.js';
import { get_next_sibling, get_node_value, node_type, remove_node } from './operations.js';
/**
* Use this variable to guard everything related to hydration code so it can be treeshaken out
@ -89,8 +89,8 @@ export function skip_nodes(remove = true) {
var node = hydrate_node;
while (true) {
if (node.nodeType === COMMENT_NODE) {
var data = /** @type {Comment} */ (node).data;
if (node_type(node) === COMMENT_NODE) {
var data = get_node_value(node) ?? '';
if (data === HYDRATION_END) {
if (depth === 0) return node;
@ -106,7 +106,7 @@ export function skip_nodes(remove = true) {
}
var next = /** @type {TemplateNode} */ (get_next_sibling(node));
if (remove) node.remove();
if (remove) remove_node(node);
node = next;
}
}
@ -116,10 +116,10 @@ export function skip_nodes(remove = true) {
* @param {TemplateNode} node
*/
export function read_hydration_instruction(node) {
if (!node || node.nodeType !== COMMENT_NODE) {
if (!node || node_type(node) !== COMMENT_NODE) {
w.hydration_mismatch();
throw HYDRATION_ERROR;
}
return /** @type {Comment} */ (node).data;
return get_node_value(node) ?? '';
}

@ -5,9 +5,15 @@ import { init_array_prototype_warnings } from '../dev/equality.js';
import { get_descriptor, is_extensible } from '../../shared/utils.js';
import { active_effect } from '../runtime.js';
import { async_mode_flag } from '../../flags/index.js';
import { TEXT_NODE, REACTION_RAN } from '#client/constants';
import {
TEXT_NODE,
REACTION_RAN,
CUSTOM_RENDERER_NODE_TYPE_MAP,
COMMENT_NODE
} from '#client/constants';
import { eager_block_effects } from '../reactivity/batch.js';
import { NAMESPACE_HTML } from '../../../constants.js';
import { current_renderer } from '../custom-renderer/state.js';
// export these for reference in the compiled code, making global name deduplication unnecessary
/** @type {Window} */
@ -78,6 +84,7 @@ export function init_operations() {
* @returns {Text}
*/
export function create_text(value = '') {
if (current_renderer) return /** @type {Text} */ (current_renderer.createTextNode(value));
return document.createTextNode(value);
}
@ -87,6 +94,8 @@ export function create_text(value = '') {
*/
/*@__NO_SIDE_EFFECTS__*/
export function get_first_child(node) {
if (current_renderer)
return /** @type {TemplateNode | null} */ (current_renderer.getFirstChild(node));
return /** @type {TemplateNode | null} */ (first_child_getter.call(node));
}
@ -96,6 +105,8 @@ export function get_first_child(node) {
*/
/*@__NO_SIDE_EFFECTS__*/
export function get_next_sibling(node) {
if (current_renderer)
return /** @type {TemplateNode | null} */ (current_renderer.getNextSibling(node));
return /** @type {TemplateNode | null} */ (next_sibling_getter.call(node));
}
@ -115,10 +126,10 @@ export function child(node, is_text) {
// Child can be null if we have an element with a single child, like `<p>{text}</p>`, where `text` is empty
if (child === null) {
child = hydrate_node.appendChild(create_text());
} else if (is_text && child.nodeType !== TEXT_NODE) {
child = /** @type {TemplateNode} */ (append_child(hydrate_node, create_text()));
} else if (is_text && node_type(child) !== TEXT_NODE) {
var text = create_text();
child?.before(text);
insert_before(child, text);
set_hydrate_node(text);
return text;
}
@ -142,7 +153,7 @@ export function first_child(node, is_text = false) {
var first = get_first_child(node);
// TODO prevent user comments with the empty string when preserveComments is true
if (first instanceof Comment && first.data === '') return get_next_sibling(first);
if (is_comment(first) && get_node_value(first) === '') return get_next_sibling(first);
return first;
}
@ -150,10 +161,10 @@ export function first_child(node, is_text = false) {
if (is_text) {
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (hydrate_node?.nodeType !== TEXT_NODE) {
if (node_type(hydrate_node) !== TEXT_NODE) {
var text = create_text();
hydrate_node?.before(text);
if (hydrate_node) insert_before(hydrate_node, text);
set_hydrate_node(text);
return text;
}
@ -187,15 +198,15 @@ export function sibling(node, count = 1, is_text = false) {
if (is_text) {
// if a sibling {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (next_sibling?.nodeType !== TEXT_NODE) {
if (node_type(next_sibling) !== TEXT_NODE) {
var text = create_text();
// If the next sibling is `null` and we're handling text then it's because
// the SSR content was empty for the text, so we need to generate a new text
// node and insert it after the last sibling
if (next_sibling === null) {
last_sibling?.after(text);
if (last_sibling) insert_after(last_sibling, text);
} else {
next_sibling.before(text);
insert_before(next_sibling, text);
}
set_hydrate_node(text);
return text;
@ -214,6 +225,15 @@ export function sibling(node, count = 1, is_text = false) {
* @returns {void}
*/
export function clear_text_content(node) {
if (current_renderer) {
var child = current_renderer.getFirstChild(node);
while (child !== null) {
var next = current_renderer.getNextSibling(child);
current_renderer.remove(child);
child = next;
}
return;
}
node.textContent = '';
}
@ -239,6 +259,10 @@ export function should_defer_append() {
* @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element}
*/
export function create_element(tag, namespace, is) {
if (current_renderer)
return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ (
current_renderer.createElement(tag)
);
let options = is ? { is } : undefined;
return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ (
document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options)
@ -246,6 +270,7 @@ export function create_element(tag, namespace, is) {
}
export function create_fragment() {
if (current_renderer) return /** @type {DocumentFragment} */ (current_renderer.createFragment());
return document.createDocumentFragment();
}
@ -254,6 +279,7 @@ export function create_fragment() {
* @returns
*/
export function create_comment(data = '') {
if (current_renderer) return /** @type {Comment} */ (current_renderer.createComment(data));
return document.createComment(data);
}
@ -264,6 +290,10 @@ export function create_comment(data = '') {
* @returns
*/
export function set_attribute(element, key, value = '') {
if (current_renderer) {
current_renderer.setAttribute(element, key, value);
return;
}
if (key.startsWith('xlink:')) {
element.setAttributeNS('http://www.w3.org/1999/xlink', key, value);
return;
@ -277,13 +307,16 @@ export function set_attribute(element, key, value = '') {
* @param {Text} text
*/
export function merge_text_nodes(text) {
// if we have a renderer we will not hydrate so we can skip this
if (current_renderer) return;
if (/** @type {string} */ (text.nodeValue).length < 65536) {
return;
}
let next = text.nextSibling;
while (next !== null && next.nodeType === TEXT_NODE) {
while (next !== null && node_type(next) === TEXT_NODE) {
next.remove();
/** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue);
@ -291,3 +324,473 @@ export function merge_text_nodes(text) {
next = text.nextSibling;
}
}
/**
* @param {TemplateNode | null} node
* @returns {node is Comment}
*/
function is_comment(node) {
if (current_renderer) return !!node && node_type(node) === COMMENT_NODE;
return node instanceof Comment;
}
/**
* @param {Node | null | undefined} node
*/
export function node_type(node) {
if (node == null) return undefined;
if (current_renderer) {
const type = current_renderer.nodeType(node);
return CUSTOM_RENDERER_NODE_TYPE_MAP[type];
}
return node?.nodeType;
}
/**
* @param {Node | null | undefined} node
*/
export function node_name(node) {
if (node == null) return undefined;
if (current_renderer) {
// for custom renderers we don't need to return the node name since all the
// checks that we do on specific node names are meant to be for the HTML
return '';
}
return node?.nodeName;
}
/**
* @param {Node} node
* @returns {TemplateNode | null}
*/
export function get_last_child(node) {
if (current_renderer)
return /** @type {TemplateNode | null} */ (current_renderer.getLastChild(node));
return /** @type {TemplateNode | null} */ (node.lastChild);
}
/**
* @param {Node} node
* @returns {TemplateNode | null}
*/
export function get_parent_node(node) {
if (current_renderer)
return /** @type {TemplateNode | null} */ (current_renderer.getParent(node));
return /** @type {TemplateNode | null} */ (node.parentNode);
}
/**
* @param {Node} parent
* @param {Node} child
* @returns {Node}
*/
export function append_child(parent, child) {
if (current_renderer) {
current_renderer.insert(parent, child, null);
return child;
}
return parent.appendChild(child);
}
/**
* Insert `new_node` before `ref_node` (equivalent to `ref_node.before(new_node)`)
* @param {ChildNode} ref_node
* @param {Node} new_node
*/
export function insert_before(ref_node, new_node) {
if (current_renderer) {
var parent = current_renderer.getParent(ref_node);
current_renderer.insert(parent, new_node, ref_node);
return;
}
ref_node.before(new_node);
}
/**
* Insert `new_node` after `ref_node` (equivalent to `ref_node.after(new_node)`)
* @param {ChildNode} ref_node
* @param {Node} new_node
*/
export function insert_after(ref_node, new_node) {
if (current_renderer) {
var parent = current_renderer.getParent(ref_node);
var next = current_renderer.getNextSibling(ref_node);
current_renderer.insert(parent, new_node, next);
return;
}
ref_node.after(new_node);
}
/**
* @param {ChildNode} node
*/
export function remove_node(node) {
if (current_renderer) {
current_renderer.remove(node);
return;
}
node.remove();
}
/**
* @param {Node} parent
* @param {ChildNode} child
* @returns {ChildNode}
*/
export function remove_child(parent, child) {
if (current_renderer) {
current_renderer.remove(child);
return child;
}
return /** @type {ChildNode} */ (parent.removeChild(child));
}
/**
* @param {ChildNode} old_node
* @param {Node} new_node
*/
export function replace_with(old_node, new_node) {
if (current_renderer) {
var parent = current_renderer.getParent(old_node);
current_renderer.insert(parent, new_node, old_node);
current_renderer.remove(old_node);
return;
}
old_node.replaceWith(new_node);
}
/**
* @param {Node} node
* @param {string} value
*/
export function set_text_content(node, value) {
if (current_renderer) {
current_renderer.setText(node, value);
return;
}
node.textContent = value;
}
/**
* @param {Node} node
* @param {string} value
*/
export function set_node_value(node, value) {
if (current_renderer) {
current_renderer.setText(node, value);
return;
}
node.nodeValue = value;
}
// --- Helpers for style attribute string manipulation (custom renderer) ---
// TODO: check if this can be improved?
/**
* @param {string} style_string
* @param {string} property
* @param {string} value
* @param {string} [priority]
* @returns {string}
*/
function set_style_property_in_string(style_string, property, value, priority) {
var declaration = property + ': ' + value + (priority ? ' !' + priority : '');
var parts = style_string.split(';');
var found = false;
for (var i = 0; i < parts.length; i++) {
var colon_index = parts[i].indexOf(':');
if (colon_index !== -1 && parts[i].substring(0, colon_index).trim() === property) {
parts[i] = ' ' + declaration;
found = true;
break;
}
}
if (!found) {
parts.push(' ' + declaration);
}
return parts
.map((p) => p.trim())
.filter(Boolean)
.join('; ');
}
/**
* @param {string} style_string
* @param {string} property
* @returns {string}
*/
function remove_style_property_in_string(style_string, property) {
return style_string
.split(';')
.filter((part) => {
var colon_index = part.indexOf(':');
if (colon_index === -1) return false;
return part.substring(0, colon_index).trim() !== property;
})
.map((p) => p.trim())
.filter(Boolean)
.join('; ');
}
/**
* @param {Node} node
* @returns {string | null}
*/
export function get_node_value(node) {
if (current_renderer) return current_renderer.getNodeValue(node);
return node.nodeValue;
}
/**
* Sets the `value` property on an element. For custom renderers, uses `setAttribute`.
* @param {Element} element
* @param {any} value
*/
export function set_element_value(element, value) {
if (current_renderer) {
current_renderer.setAttribute(element, 'value', value ?? '');
return;
}
// @ts-expect-error
element.value = value ?? '';
}
/**
* Sets the `checked` property on an element. For custom renderers, uses `setAttribute`.
* @param {Element} element
* @param {boolean} checked
*/
export function set_element_checked(element, checked) {
if (current_renderer) {
if (checked) {
current_renderer.setAttribute(element, 'checked', '');
} else {
current_renderer.removeAttribute(element, 'checked');
}
return;
}
// @ts-expect-error
element.checked = checked;
}
/**
* Sets the `defaultValue` property on an element without affecting the current `value`.
* For custom renderers, uses `setAttribute` on `defaultvalue`.
* @param {Element} element
* @param {string} value
*/
export function set_element_default_value(element, value) {
if (current_renderer) {
current_renderer.setAttribute(element, 'defaultValue', value);
return;
}
// @ts-expect-error
const existing_value = element.value;
// @ts-expect-error
element.defaultValue = value;
// @ts-expect-error
element.value = existing_value;
}
/**
* Sets the `defaultChecked` property on an element without affecting the current `checked` state.
* For custom renderers, uses `setAttribute` on `defaultchecked`.
* @param {Element} element
* @param {boolean} checked
*/
export function set_element_default_checked(element, checked) {
if (current_renderer) {
if (checked) {
current_renderer.setAttribute(element, 'defaultChecked', '');
} else {
current_renderer.removeAttribute(element, 'defaultChecked');
}
return;
}
// @ts-expect-error
const existing_value = element.checked;
// @ts-expect-error
element.defaultChecked = checked;
// @ts-expect-error
element.checked = existing_value;
}
/**
* @param {Element} element
* @param {string} name
* @returns {string | null}
*/
export function get_attribute(element, name) {
if (current_renderer) return current_renderer.getAttribute(element, name);
return element.getAttribute(name);
}
/**
* @param {Element} element
* @param {string} name
*/
export function remove_attribute(element, name) {
if (current_renderer) {
current_renderer.removeAttribute(element, name);
return;
}
element.removeAttribute(name);
}
/**
* @param {Element} element
* @param {string} name
* @returns {boolean}
*/
export function has_attribute(element, name) {
if (current_renderer) return current_renderer.hasAttribute(element, name);
return element.hasAttribute(name);
}
/**
* @param {Element} element
* @param {string} value
*/
export function set_inner_html(element, value) {
if (current_renderer) {
throw new Error('setInnerHTML is not supported with custom renderers');
}
element.innerHTML = value;
}
/**
* @param {Node} node
* @param {boolean} deep
* @returns {Node}
*/
export function clone_node(node, deep) {
if (current_renderer) {
throw new Error('cloneNode is not supported with custom renderers');
}
return node.cloneNode(deep);
}
/**
* @param {Node} node
* @param {boolean} deep
* @returns {Node}
*/
export function import_node(node, deep) {
if (current_renderer) {
throw new Error('importNode is not supported with custom renderers');
}
return document.importNode(node, deep);
}
/**
* @param {EventTarget} target
* @param {string} type
* @param {EventListenerOrEventListenerObject} handler
* @param {boolean | AddEventListenerOptions} [options]
*/
export function add_event_listener(target, type, handler, options) {
if (current_renderer) {
current_renderer.addEventListener(target, type, handler, options);
return;
}
target.addEventListener(type, handler, options);
}
/**
* @param {EventTarget} target
* @param {string} type
* @param {EventListenerOrEventListenerObject} handler
* @param {boolean | EventListenerOptions} [options]
*/
export function remove_event_listener(target, type, handler, options) {
if (current_renderer) {
current_renderer.removeEventListener(target, type, handler, options);
return;
}
target.removeEventListener(type, handler, options);
}
/**
* @param {EventTarget} target
* @param {Event} event
* @returns {boolean}
*/
export function dispatch_event(target, event) {
if (current_renderer) {
// is only used in SSR which is not a thing for custom renderers
throw new Error('dispatchEvent is not supported with custom renderers');
}
return target.dispatchEvent(event);
}
/**
* @param {HTMLElement} element
* @param {string} property
* @param {string} value
* @param {string} [priority]
*/
export function style_set_property(element, property, value, priority) {
if (current_renderer) {
var style = current_renderer.getAttribute(element, 'style') || '';
var updated = set_style_property_in_string(style, property, value, priority);
current_renderer.setAttribute(element, 'style', updated);
return;
}
element.style.setProperty(property, value, priority);
}
/**
* @param {HTMLElement} element
* @param {string} property
*/
export function style_remove_property(element, property) {
if (current_renderer) {
var style = current_renderer.getAttribute(element, 'style') || '';
var updated = remove_style_property_in_string(style, property);
current_renderer.setAttribute(element, 'style', updated);
return;
}
element.style.removeProperty(property);
}
/**
* @param {HTMLElement} element
* @param {string} value
*/
export function set_css_text(element, value) {
if (current_renderer) {
current_renderer.setAttribute(element, 'style', value);
return;
}
element.style.cssText = value;
}
/**
* @param {Element} element
* @param {string} name
* @param {boolean} force
*/
export function class_list_toggle(element, name, force) {
if (current_renderer) {
const classes = current_renderer.getAttribute(element, 'class')?.split(/\s+/) ?? [];
const has_class = classes.includes(name);
if (force === has_class) {
return;
}
if (force) {
classes.push(name);
} else {
const index = classes.indexOf(name);
if (index !== -1) {
classes.splice(index, 1);
}
}
current_renderer.setAttribute(element, 'class', classes.join(' '));
return;
}
element.classList.toggle(name, force);
}

@ -1,4 +1,4 @@
import { create_element } from './operations.js';
import { create_element, set_inner_html } from './operations.js';
const policy =
// We gotta write it like this because after downleveling the pure comment may end up in the wrong location
@ -20,6 +20,6 @@ export function create_trusted_html(html) {
*/
export function create_fragment_from_html(html) {
var elem = create_element('template');
elem.innerHTML = create_trusted_html(html.replaceAll('<!>', '<!---->')); // XHTML compliance
set_inner_html(elem, create_trusted_html(html.replaceAll('<!>', '<!---->'))); // XHTML compliance
return elem.content;
}

@ -1,19 +1,12 @@
/** @import { Effect, EffectNodes, TemplateNode } from '#client' */
/** @import { TemplateStructure } from './types' */
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
import {
create_text,
get_first_child,
get_next_sibling,
is_firefox,
create_element,
create_fragment,
create_comment,
set_attribute,
merge_text_nodes
} from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { active_effect } from '../runtime.js';
COMMENT_NODE,
DOCUMENT_FRAGMENT_NODE,
IS_XHTML,
REACTION_RAN,
TEXT_NODE
} from '#client/constants';
import {
NAMESPACE_MATHML,
NAMESPACE_SVG,
@ -22,13 +15,30 @@ import {
TEMPLATE_USE_MATHML,
TEMPLATE_USE_SVG
} from '../../../constants.js';
import { current_renderer } from '../custom-renderer/state.js';
import { active_effect } from '../runtime.js';
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
import {
COMMENT_NODE,
DOCUMENT_FRAGMENT_NODE,
IS_XHTML,
REACTION_RAN,
TEXT_NODE
} from '#client/constants';
append_child,
clone_node,
create_comment,
create_element,
create_fragment,
create_text,
get_first_child,
get_last_child,
get_node_value,
import_node,
insert_before,
is_firefox,
merge_text_nodes,
node_name,
node_type,
replace_with,
set_attribute,
set_text_content
} from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
const TEMPLATE_TAG = IS_XHTML ? 'template' : 'TEMPLATE';
const SCRIPT_TAG = IS_XHTML ? 'script' : 'SCRIPT';
@ -75,12 +85,12 @@ export function from_html(content, flags) {
}
var clone = /** @type {TemplateNode} */ (
use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)
use_import_node || is_firefox ? import_node(node, true) : clone_node(node, true)
);
if (is_fragment) {
var start = /** @type {TemplateNode} */ (get_first_child(clone));
var end = /** @type {TemplateNode} */ (clone.lastChild);
var end = /** @type {TemplateNode} */ (get_last_child(clone));
assign_nodes(start, end);
} else {
@ -122,20 +132,20 @@ function from_namespace(content, flags, ns = 'svg') {
var root = /** @type {Element} */ (get_first_child(fragment));
if (is_fragment) {
node = document.createDocumentFragment();
node = create_fragment();
while (get_first_child(root)) {
node.appendChild(/** @type {TemplateNode} */ (get_first_child(root)));
append_child(node, /** @type {TemplateNode} */ (get_first_child(root)));
}
} else {
node = /** @type {Element} */ (get_first_child(root));
}
}
var clone = /** @type {TemplateNode} */ (node.cloneNode(true));
var clone = /** @type {TemplateNode} */ (clone_node(node, true));
if (is_fragment) {
var start = /** @type {TemplateNode} */ (get_first_child(clone));
var end = /** @type {TemplateNode} */ (clone.lastChild);
var end = /** @type {TemplateNode} */ (get_last_child(clone));
assign_nodes(start, end);
} else {
@ -173,13 +183,13 @@ function fragment_from_tree(structure, ns) {
for (var item of structure) {
if (typeof item === 'string') {
fragment.append(create_text(item));
append_child(fragment, create_text(item));
continue;
}
// if `preserveComments === true`, comments are represented as `['// <data>']`
if (item === undefined || item[0][0] === '/') {
fragment.append(create_comment(item ? item[0].slice(3) : ''));
append_child(fragment, create_comment(item ? item[0].slice(3) : ''));
continue;
}
@ -195,16 +205,18 @@ function fragment_from_tree(structure, ns) {
if (children.length > 0) {
var target =
element.nodeName === TEMPLATE_TAG
? /** @type {HTMLTemplateElement} */ (element).content
node_name(element) === TEMPLATE_TAG
? // this is safe for custom renderers because the name will never be `template` due to how `node_name` works
/** @type {HTMLTemplateElement} */ (element).content
: element;
target.append(
fragment_from_tree(children, element.nodeName === 'foreignObject' ? undefined : namespace)
append_child(
target,
fragment_from_tree(children, node_name(element) === 'foreignObject' ? undefined : namespace)
);
}
fragment.append(element);
append_child(fragment, element);
}
return fragment;
@ -229,7 +241,10 @@ export function from_tree(structure, flags) {
return hydrate_node;
}
if (node === undefined) {
// for the custom renderer we skip the cloning and create new nodes every time...a bit less efficient
// but save custom renderers implementors from having to implement importNode or cloneNode
// which can be a pain
if (node === undefined || current_renderer != null) {
const ns =
(flags & TEMPLATE_USE_SVG) !== 0
? NAMESPACE_SVG
@ -242,12 +257,16 @@ export function from_tree(structure, flags) {
}
var clone = /** @type {TemplateNode} */ (
use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)
current_renderer != null
? node
: use_import_node || is_firefox
? import_node(node, true)
: clone_node(node, true)
);
if (is_fragment) {
var start = /** @type {TemplateNode} */ (get_first_child(clone));
var end = /** @type {TemplateNode} */ (clone.lastChild);
var end = /** @type {TemplateNode} */ (get_last_child(clone));
assign_nodes(start, end);
} else {
@ -272,12 +291,13 @@ export function with_script(fn) {
* @returns {Node | Node[]}
*/
function run_scripts(node) {
// this should be custom renderer safe since we never emit `with_script` in that case
// scripts were SSR'd, in which case they will run
if (hydrating) return node;
const is_fragment = node.nodeType === DOCUMENT_FRAGMENT_NODE;
const is_fragment = node_type(node) === DOCUMENT_FRAGMENT_NODE;
const scripts =
/** @type {HTMLElement} */ (node).nodeName === SCRIPT_TAG
node_name(node) === SCRIPT_TAG
? [/** @type {HTMLScriptElement} */ (node)]
: node.querySelectorAll('script');
@ -285,21 +305,22 @@ function run_scripts(node) {
for (const script of scripts) {
const clone = create_element('script');
for (var attribute of script.attributes) {
clone.setAttribute(attribute.name, attribute.value);
set_attribute(clone, attribute.name, attribute.value);
}
clone.textContent = script.textContent;
set_text_content(clone, script.textContent ?? '');
// The script has changed - if it's at the edges, the effect now points at dead nodes
if (is_fragment ? node.firstChild === script : node === script) {
if (is_fragment ? get_first_child(node) === script : node === script) {
effect.nodes.start = clone;
}
if (is_fragment ? node.lastChild === script : node === script) {
if (is_fragment ? get_last_child(node) === script : node === script) {
effect.nodes.end = clone;
}
script.replaceWith(clone);
replace_with(script, clone);
}
return node;
}
@ -317,9 +338,9 @@ export function text(value = '') {
var node = hydrate_node;
if (node.nodeType !== TEXT_NODE) {
if (node_type(node) !== TEXT_NODE) {
// if an {expression} is empty during SSR, we need to insert an empty text node
node.before((node = create_text()));
insert_before(node, (node = create_text()));
set_hydrate_node(node);
} else {
merge_text_nodes(/** @type {Text} */ (node));
@ -339,10 +360,11 @@ export function comment() {
return hydrate_node;
}
var frag = document.createDocumentFragment();
var start = document.createComment('');
var frag = create_fragment();
var start = create_comment('');
var anchor = create_text();
frag.append(start, anchor);
append_child(frag, start);
append_child(frag, anchor);
assign_nodes(start, anchor);
@ -375,27 +397,27 @@ export function append(anchor, dom) {
return;
}
anchor.before(/** @type {Node} */ (dom));
insert_before(anchor, /** @type {Node} */ (dom));
}
/**
* Create (or hydrate) an unique UID for the component instance.
*/
export function props_id() {
let node_value;
if (
hydrating &&
hydrate_node &&
hydrate_node.nodeType === COMMENT_NODE &&
hydrate_node.textContent?.startsWith(`$`)
node_type(hydrate_node) === COMMENT_NODE &&
(node_value = get_node_value(hydrate_node))?.startsWith(`$`)
) {
const id = hydrate_node.textContent.substring(1);
const id = node_value.substring(1);
hydrate_next();
return id;
}
// @ts-expect-error This way we ensure the id is unique even across Svelte runtimes
(window.__svelte ??= {}).uid ??= 1;
(globalThis.__svelte ??= {}).uid ??= 1;
// @ts-expect-error
return `c${window.__svelte.uid++}`;
return `c${globalThis.__svelte.uid++}`;
}

@ -361,6 +361,22 @@ export function invalid_snippet() {
}
}
/**
* `createRawSnippet` cannot be used with a custom renderer
* @returns {never}
*/
export function invalid_snippet_in_custom_renderer() {
if (DEV) {
const error = new Error(`invalid_snippet_in_custom_renderer\n\`createRawSnippet\` cannot be used with a custom renderer\nhttps://svelte.dev/e/invalid_snippet_in_custom_renderer`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/invalid_snippet_in_custom_renderer`);
}
}
/**
* `%name%(...)` cannot be used in runes mode
* @param {string} name
@ -445,6 +461,22 @@ export function set_context_after_init() {
}
}
/**
* A snippet created in a component with a custom renderer cannot be rendered by a different renderer
* @returns {never}
*/
export function snippet_renderer_mismatch() {
if (DEV) {
const error = new Error(`snippet_renderer_mismatch\nA snippet created in a component with a custom renderer cannot be rendered by a different renderer\nhttps://svelte.dev/e/snippet_renderer_mismatch`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/snippet_renderer_mismatch`);
}
}
/**
* Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.
* @returns {never}

@ -18,7 +18,12 @@ export { css_props } from './dom/blocks/css-props.js';
export { index, each } from './dom/blocks/each.js';
export { html } from './dom/blocks/html.js';
export { sanitize_slots, slot } from './dom/blocks/slot.js';
export { snippet, wrap_snippet } from './dom/blocks/snippet.js';
export {
snippet,
wrap_snippet,
renderer_snippet,
validate_snippet_renderer
} from './dom/blocks/snippet.js';
export { component } from './dom/blocks/svelte-component.js';
export { element } from './dom/blocks/svelte-element.js';
export { head } from './dom/blocks/svelte-head.js';
@ -181,3 +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';

@ -9,6 +9,7 @@ import {
set_dev_stack
} from '../context.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { current_renderer, set_renderer } from '../custom-renderer/state.js';
import { invoke_error_boundary } from '../error-handling.js';
import {
active_effect,
@ -113,6 +114,7 @@ export function capture() {
var previous_reaction = active_reaction;
var previous_component_context = component_context;
var previous_batch = /** @type {Batch} */ (current_batch);
var previous_renderer = current_renderer;
if (DEV) {
var previous_dev_stack = dev_stack;
@ -123,6 +125,8 @@ export function capture() {
set_active_reaction(previous_reaction);
set_component_context(previous_component_context);
set_renderer(previous_renderer);
if (activate_batch && (previous_effect.f & DESTROYED) === 0) {
// TODO we only need optional chaining here because `{#await ...}` blocks
// are anomalous. Once we retire them we can get rid of it
@ -252,6 +256,7 @@ export function unset_context(deactivate_batch = true) {
set_active_effect(null);
set_active_reaction(null);
set_component_context(null);
set_renderer(null);
if (deactivate_batch) current_batch?.deactivate();
if (DEV) {

@ -40,12 +40,13 @@ import {
import * as e from '../errors.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { get_next_sibling, remove_node, append_child } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
import { push_renderer, current_renderer } from '../custom-renderer/state.js';
/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
@ -113,7 +114,8 @@ function create_effect(type, fn) {
prev: null,
teardown: null,
wv: 0,
ac: null
ac: null,
r: current_renderer
};
if (DEV) {
@ -514,6 +516,8 @@ export function destroy_block_effect_children(signal) {
export function destroy_effect(effect, remove_dom = true) {
var removed = false;
var pop_renderer = effect.r !== null ? push_renderer(effect.r) : null;
if (
(remove_dom || (effect.f & HEAD_EFFECT) !== 0) &&
effect.nodes !== null &&
@ -562,7 +566,10 @@ export function destroy_effect(effect, remove_dom = true) {
effect.nodes =
effect.ac =
effect.b =
effect.r =
null;
pop_renderer?.();
}
/**
@ -575,7 +582,7 @@ export function remove_effect_dom(node, end) {
/** @type {TemplateNode | null} */
var next = node === end ? null : get_next_sibling(node);
node.remove();
remove_node(/** @type {ChildNode} */ (node));
node = next;
}
}
@ -734,6 +741,8 @@ export function aborted(effect = /** @type {Effect} */ (active_effect)) {
export function move_effect(effect, fragment) {
if (!effect.nodes) return;
var pop_renderer = effect.r !== null ? push_renderer(effect.r) : null;
/** @type {TemplateNode | null} */
var node = effect.nodes.start;
var end = effect.nodes.end;
@ -742,7 +751,9 @@ export function move_effect(effect, fragment) {
/** @type {TemplateNode | null} */
var next = node === end ? null : get_next_sibling(node);
fragment.append(node);
append_child(fragment, node);
node = next;
}
pop_renderer?.();
}

@ -7,6 +7,7 @@ import type {
TransitionManager
} from '#client';
import type { Boundary } from '../dom/blocks/boundary';
import type { Renderer } from '../custom-renderer/types';
export interface Signal {
/** Flags bitmask */
@ -94,6 +95,8 @@ export interface Effect extends Reaction {
parent: Effect | null;
/** The boundary this effect belongs to */
b: Boundary | null;
/** The renderer this effect was created with */
r: Renderer | null;
/** Dev only */
component_function?: any;
/** Dev only. Only set for certain block effects. Contains a reference to the stack that represents the render tree */

@ -6,7 +6,14 @@ import {
create_text,
get_first_child,
get_next_sibling,
init_operations
append_child,
add_event_listener,
remove_event_listener,
get_parent_node,
remove_child,
set_node_value,
get_node_value,
node_type
} from './dom/operations.js';
import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';
import { active_effect } from './runtime.js';
@ -47,10 +54,10 @@ export function set_text(text, value) {
// For objects, we apply string coercion (which might make things like $state array references in the template reactive) before diffing
var str = value == null ? '' : typeof value === 'object' ? `${value}` : value;
// @ts-expect-error
if (str !== (text.__t ??= text.nodeValue)) {
if (str !== (text.__t ??= get_node_value(text))) {
// @ts-expect-error
text.__t = str;
text.nodeValue = `${str}`;
set_node_value(text, `${str}`);
}
}
@ -94,7 +101,6 @@ export function mount(component, options) {
* @returns {Exports}
*/
export function hydrate(component, options) {
init_operations();
options.intro = options.intro ?? false;
const target = options.target;
const was_hydrating = hydrating;
@ -105,7 +111,7 @@ export function hydrate(component, options) {
while (
anchor &&
(anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START)
(node_type(anchor) !== COMMENT_NODE || get_node_value(anchor) !== HYDRATION_START)
) {
anchor = get_next_sibling(anchor);
}
@ -139,8 +145,6 @@ export function hydrate(component, options) {
e.hydration_failed();
}
// If an error occurred above, the operations might not yet have been initialised.
init_operations();
clear_text_content(target);
set_hydrating(false);
@ -164,14 +168,12 @@ function _mount(
Component,
{ target, anchor, props = {}, events, context, intro = true, transformError }
) {
init_operations();
/** @type {Exports} */
// @ts-expect-error will be defined because the render effect runs synchronously
var component = undefined;
var unmount = component_root(() => {
var anchor_node = anchor ?? target.appendChild(create_text());
var anchor_node = anchor ?? /** @type {Text} */ (append_child(target, create_text()));
boundary(
/** @type {TemplateNode} */ (anchor_node),
@ -202,8 +204,8 @@ function _mount(
if (
hydrate_node === null ||
hydrate_node.nodeType !== COMMENT_NODE ||
/** @type {Comment} */ (hydrate_node).data !== HYDRATION_END
node_type(hydrate_node) !== COMMENT_NODE ||
get_node_value(hydrate_node) !== HYDRATION_END
) {
w.hydration_mismatch();
throw HYDRATION_ERROR;
@ -246,7 +248,7 @@ function _mount(
var count = counts.get(event_name);
if (count === undefined) {
node.addEventListener(event_name, handle_event_propagation, { passive });
add_event_listener(node, event_name, handle_event_propagation, { passive });
counts.set(event_name, 1);
} else {
counts.set(event_name, count + 1);
@ -265,7 +267,7 @@ function _mount(
var count = /** @type {number} */ (counts.get(event_name));
if (--count == 0) {
node.removeEventListener(event_name, handle_event_propagation);
remove_event_listener(node, event_name, handle_event_propagation);
counts.delete(event_name);
if (counts.size === 0) {
@ -280,7 +282,8 @@ function _mount(
root_event_handles.delete(event_handle);
if (anchor_node !== anchor) {
anchor_node.parentNode?.removeChild(anchor_node);
var parent = get_parent_node(anchor_node);
if (parent) remove_child(parent, /** @type {ChildNode} */ (anchor_node));
}
};
});

@ -59,6 +59,7 @@ import { captured_signals } from './legacy.js';
import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js';
import * as w from './warnings.js';
import { push_renderer } from './custom-renderer/state.js';
let is_updating_effect = false;
@ -449,6 +450,8 @@ export function update_effect(effect) {
active_effect = effect;
is_updating_effect = true;
var pop_renderer = effect.r !== null ? push_renderer(effect.r) : null;
if (DEV) {
var previous_component_fn = dev_current_component_function;
set_dev_current_component_function(effect.component_function);
@ -483,6 +486,8 @@ export function update_effect(effect) {
is_updating_effect = was_updating_effect;
active_effect = previous_effect;
pop_renderer?.();
if (DEV) {
set_dev_current_component_function(previous_component_fn);
set_dev_stack(previous_stack);

@ -2,5 +2,5 @@ import { PUBLIC_VERSION } from '../version.js';
if (typeof window !== 'undefined') {
// @ts-expect-error
((window.__svelte ??= {}).v ??= new Set()).add(PUBLIC_VERSION);
((globalThis.__svelte ??= {}).v ??= new Set()).add(PUBLIC_VERSION);
}

@ -0,0 +1,3 @@
import { init_operations } from './client/dom/operations.js';
init_operations();

@ -0,0 +1 @@
export { createRenderer } from '../internal/client/custom-renderer/index.js';

@ -0,0 +1,240 @@
/**
* An object-based renderer for testing Svelte's custom renderer functionality.
*
* Runs in plain Node.js no DOM required. Creates a tree of plain objects
* with simple properties (type, name, children, attributes, value, etc).
* Proves that Svelte can render into non-DOM targets.
*/
import { createRenderer } from '../../src/renderer/index.js';
/**
* @typedef {{ type: 'element', name: string, attributes: Record<string, string>, children: ObjNode[], listeners: Record<string, Array<{handler: any, options?: any}>>, parent: ObjNode | null }} ObjElement
* @typedef {{ type: 'text', value: string, parent: ObjNode | null }} ObjText
* @typedef {{ type: 'comment', value: string, parent: ObjNode | null, before: (node: any)=> void }} ObjComment
* @typedef {{ type: 'fragment', children: ObjNode[], parent: ObjNode | null }} ObjFragment
* @typedef {ObjElement | ObjText | ObjComment | ObjFragment} ObjNode
*/
/**
* @param {ObjNode & { children?: ObjNode[] }} parent
* @param {ObjNode} node
* @param {ObjNode | null} anchor
*/
function insert_node(parent, node, anchor) {
if (node.type === 'fragment') {
const children = [...(node.children ?? [])];
for (const child of children) {
insert_node(parent, child, anchor);
}
return;
}
// Remove from old parent first
if (node.parent) {
remove_from_parent(node);
}
const children = /** @type {ObjNode[]} */ (parent.children);
node.parent = parent;
if (anchor === null) {
children.push(node);
} else {
const idx = children.indexOf(anchor);
if (idx === -1) throw new Error('Anchor not found in parent');
children.splice(idx, 0, node);
}
}
/** @param {ObjNode} node */
function remove_from_parent(node) {
const parent = node.parent;
if (!parent || !('children' in parent)) return;
const children = parent.children;
const idx = children.indexOf(node);
if (idx === -1) return;
node.parent = null;
children.splice(idx, 1);
}
/**
* @type {Array<DocumentFragment | Node>}
*/
export const dom_elements = [];
const renderer = createRenderer({
createFragment() {
return /** @type {ObjFragment} */ ({
type: 'fragment',
children: [],
parent: null
});
},
createElement(name) {
return /** @type {ObjElement} */ ({
type: 'element',
name,
attributes: {},
children: [],
listeners: {},
parent: null
});
},
createTextNode(data) {
return /** @type {ObjText} */ ({
type: 'text',
value: data,
parent: null
});
},
createComment(data) {
return /** @type {ObjComment} */ ({
type: 'comment',
value: data,
parent: null,
// adding this allows for this renderer to interleave with a DOM-based renderer
// the argument will be the DOM node that represent a DOM Component being mounted
before(node) {
dom_elements.push(node);
}
});
},
nodeType(node) {
return node.type;
},
getNodeValue(node) {
if (node.type === 'text' || node.type === 'comment') return node.value;
return null;
},
getAttribute(element, name) {
return element.attributes?.[name] ?? null;
},
setAttribute(element, key, value) {
element.attributes[key] = String(value);
},
removeAttribute(element, name) {
delete element.attributes[name];
},
hasAttribute(element, name) {
return name in (element.attributes ?? {});
},
setText(node, text) {
if (node.type === 'text' || node.type === 'comment') {
node.value = text;
}
},
getFirstChild(element) {
return element.children?.[0] ?? null;
},
getLastChild(element) {
const c = element.children;
return c?.[c.length - 1] ?? null;
},
getNextSibling(node) {
const parent = node.parent;
if (!parent || !('children' in parent)) return null;
const idx = parent.children.indexOf(node);
return parent.children[idx + 1] ?? null;
},
insert(parent, element, anchor) {
insert_node(parent, element, anchor);
},
remove(node) {
remove_from_parent(node);
},
getParent(node) {
return node.parent ?? null;
},
addEventListener(target, type, handler, options) {
if (target.type !== 'element') return;
if (!target.listeners) target.listeners = {};
if (!target.listeners[type]) target.listeners[type] = [];
target.listeners[type].push({ handler, options });
},
removeEventListener(target, type, handler, options) {
if (target.type !== 'element') return;
if (!target.listeners?.[type]) return;
target.listeners[type] = target.listeners[type].filter(
(/** @type {any} */ l) => l.handler !== handler
);
}
});
// ---- Test helpers ----
/**
* Create a root object for mounting components into.
* @returns {ObjFragment}
*/
export function create_root() {
return renderer.createFragment();
}
/**
* Dispatch a synthetic event on an object node.
* @param {any} node
* @param {string} type
* @param {any} [detail]
*/
export function dispatch_event(node, type, detail) {
const listeners = node.listeners?.[type];
if (!listeners) return;
const event = { type, detail, target: node };
for (const { handler } of listeners) {
handler(event);
}
}
/**
* Serialize an object tree to an HTML-like string for easy assertion.
* @param {ObjNode} node
* @returns {string}
*/
export function serialize(node) {
if (!node) return '';
switch (node.type) {
case 'text':
return node.value ?? '';
case 'comment':
return '';
case 'fragment':
return node.children.map(serialize).join('');
case 'element': {
const tag = node.name;
let attrs = '';
const sorted_keys = Object.keys(node.attributes).sort();
for (const key of sorted_keys) {
attrs += ` ${key}="${node.attributes[key]}"`;
}
const children = node.children.map(serialize).join('');
return `<${tag}${attrs}>${children}</${tag}>`;
}
default:
return '';
}
}
export default renderer;

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script>
let { message } = $props();
</script>
<div>
<span>{message}</span>
</div>

@ -0,0 +1,14 @@
import { test } from '../../test-dom.test';
export default test({
// this is the custom rendered component...it doesn't have anything inside because the part of the renderer
// responsible for the interleaving is the `before` function on the comment node which only push into `dom_elements` in this case
html: '<custom></custom>',
test({ assert, dom_elements }) {
// we then get the element out of dom_elements
const [div] = dom_elements;
// check that is an actual DOM element and that it has the expected content
assert.instanceOf(div, HTMLDivElement);
assert.equal(div.outerHTML, '<div><span>hello from child</span></div>');
}
});

@ -0,0 +1,7 @@
<script>
import Child from './Child.svelte';
</script>
<custom>
<Child message="hello from child"></Child>
</custom>

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script>
let { greeting } = $props();
</script>
<div>
{@render greeting('world')}
</div>

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script module>
export { greeting };
</script>
{#snippet greeting(name)}
<span>hello {name}</span>
{/snippet}

@ -0,0 +1,9 @@
import { test } from '../../test-dom.test';
export default test({
// The key assertion is that this test does not throw.
// A DOM module snippet imported by a custom renderer component and
// passed to a DOM child component should work without errors.
// We don't check html because the DOM child renders into the real DOM,
// not the custom renderer tree.
});

@ -0,0 +1,8 @@
<script>
import { greeting } from './DomSource.svelte';
import DomChild from './DomChild.svelte';
</script>
<div>
<DomChild {greeting}></DomChild>
</div>

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script>
let { greeting } = $props();
</script>
<div>
{@render greeting('world')}
</div>

@ -0,0 +1,6 @@
import { test } from '../../test-dom.test';
export default test({
error:
'A snippet created in a component with a custom renderer cannot be rendered by a different renderer'
});

@ -0,0 +1,11 @@
<script>
import Child from './Child.svelte';
</script>
{#snippet greeting(name)}
<span>hello {name}</span>
{/snippet}
<div>
<Child {greeting}></Child>
</div>

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script module>
export { greeting };
</script>
{#snippet greeting(name)}
<span>hello {name}</span>
{/snippet}

@ -0,0 +1,6 @@
import { test } from '../../test-dom.test';
export default test({
error:
'A snippet created in a component with a custom renderer cannot be rendered by a different renderer'
});

@ -0,0 +1,7 @@
<script>
import { greeting } from './DomComponent.svelte';
</script>
<div>
{@render greeting('world')}
</div>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
compile_error: '`animate:` is not compatible with `customRenderer`'
});

@ -0,0 +1,9 @@
<script>
import { flip } from 'svelte/animate';
let items = [1, 2, 3];
</script>
{#each items as item (item)}
<div animate:flip>{item}</div>
{/each}

@ -0,0 +1,37 @@
import { test } from '../../test';
export default test({
test({ assert, target }) {
const elements = target.children.filter((/** @type {any} */ n) => n.type === 'element');
assert.equal(elements.length, 4);
// Static camelCase attribute should preserve casing
const div1 = elements[0];
assert.equal(div1.name, 'div');
assert.equal(div1.attributes['dataColor'], 'red');
// Should NOT have a lowercased version
assert.equal(div1.attributes['datacolor'], undefined);
// Dynamic camelCase attribute should preserve casing
const div2 = elements[1];
assert.equal(div2.name, 'div');
assert.equal(div2.attributes['viewBox'], '0 0 100 100');
// Should NOT have a lowercased version
assert.equal(div2.attributes['viewbox'], undefined);
// Static tabIndex should preserve casing
const span = elements[2];
assert.equal(span.name, 'span');
assert.equal(span.attributes['tabIndex'], '0');
// Should NOT have a lowercased version
assert.equal(span.attributes['tabindex'], undefined);
// Spread camelCase attributes should preserve casing
const p = elements[3];
assert.equal(p.name, 'p');
assert.equal(p.attributes['dataValue'], 'spread');
// Should NOT have a lowercased version
assert.equal(p.attributes['datavalue'], undefined);
}
});

@ -0,0 +1,9 @@
<script>
let box = $state('0 0 100 100');
let spread = $derived({ dataValue: 'spread', onClick: () => {} });
</script>
<div dataColor="red">static camelCase</div>
<div viewBox={box}>dynamic camelCase</div>
<span tabIndex="0">static tabIndex</span>
<p {...spread}>spread camelCase</p>

@ -0,0 +1,25 @@
import { test } from '../../test';
export default test({
test({ assert, target, serialize }) {
const html = serialize(target);
assert.equal(
html,
'<div class="container" data-color="red"><span id="label">colored</span></div>'
);
// Verify individual attribute access on the object node
const div = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'div'
);
assert.ok(div);
assert.equal(div.attributes['class'], 'container');
assert.equal(div.attributes['data-color'], 'red');
const span = div.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'span'
);
assert.ok(span);
assert.equal(span.attributes['id'], 'label');
}
});

@ -0,0 +1,7 @@
<script>
let color = $state('red');
</script>
<div class="container" data-color={color}>
<span id="label">colored</span>
</div>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<p>hello</p>'
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save