From a03b3f30143d634e1ab659c55d84b844d2f5c708 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Tue, 17 Mar 2026 10:26:49 +0100 Subject: [PATCH 01/88] feat: custom renderers api --- packages/svelte/package.json | 4 +++ packages/svelte/renderer.d.ts | 1 + packages/svelte/scripts/generate-types.js | 3 +- .../compiler/phases/1-parse/read/options.js | 4 +++ .../phases/2-analyze/visitors/Attribute.js | 7 +++-- .../3-transform/client/transform-client.js | 28 +++++++++++++++++-- .../client/transform-template/index.js | 3 +- .../client/visitors/RegularElement.js | 3 +- packages/svelte/src/compiler/types/index.d.ts | 4 +++ .../svelte/src/compiler/types/template.d.ts | 3 +- .../svelte/src/compiler/utils/builders.js | 4 +-- .../svelte/src/compiler/validate-options.js | 2 ++ .../internal/client/custom-renderer/index.js | 28 +++++++++++++++++++ .../internal/client/custom-renderer/state.js | 20 +++++++++++++ packages/svelte/src/internal/client/index.js | 1 + packages/svelte/src/renderer/index.js | 1 + packages/svelte/types/index.d.ts | 17 ++++++++++- 17 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 packages/svelte/renderer.d.ts create mode 100644 packages/svelte/src/internal/client/custom-renderer/index.js create mode 100644 packages/svelte/src/internal/client/custom-renderer/state.js create mode 100644 packages/svelte/src/renderer/index.js diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 49c53a26eb..e06a9bf4a8 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -90,6 +90,10 @@ "./reactivity/window": { "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", diff --git a/packages/svelte/renderer.d.ts b/packages/svelte/renderer.d.ts new file mode 100644 index 0000000000..a1a0ce5223 --- /dev/null +++ b/packages/svelte/renderer.d.ts @@ -0,0 +1 @@ +import './types/index.js'; diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 0ee6004d4a..bdbde7a505 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -8,7 +8,7 @@ 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 +44,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`, diff --git a/packages/svelte/src/compiler/phases/1-parse/read/options.js b/packages/svelte/src/compiler/phases/1-parse/read/options.js index 2677fb3b61..dc14bd47f8 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/options.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/options.js @@ -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 = {}; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 2b7d636606..838cbf0413 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -59,8 +59,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 (!context.state.options.customRenderer) { + node.metadata.delegated = + parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2)); + } } } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index b50a73b8b6..75e12876a2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -138,6 +138,19 @@ const visitors = { VariableDeclaration }; +/** + * @param {string} custom_renderer_module + */ +function custom_renderer_imports(custom_renderer_module) { + const imports = [ + b.import_all('$', 'svelte/internal/client'), + ] + if(custom_renderer_module){ + imports.push(b.imports([['$renderer', '$renderer', true]], custom_renderer_module)); + } + return imports; +} + /** * @param {ComponentAnalysis} analysis * @param {ValidatedCompileOptions} options @@ -151,7 +164,9 @@ 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: [ + ...custom_renderer_imports(options.customRenderer), ...analysis.instance_body.hoisted + ], node: /** @type {any} */ (null), // populated by the root node legacy_reactive_imports: [], legacy_reactive_statements: new Map(), @@ -392,6 +407,13 @@ export function client_component(analysis, options) { ); } + if (options.customRenderer) { + 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'))); + } + let should_inject_props = should_inject_context || analysis.needs_props || @@ -562,7 +584,9 @@ export function client_component(analysis, options) { body.unshift(b.imports([], 'svelte/internal/flags/tracing')); } - if (options.discloseVersion) { + // disclose version attach the svelte version to `window` which is not guaranteed + // to be a thing in custom renderers environments + if (options.discloseVersion && !options.customRenderer) { body.unshift(b.imports([], 'svelte/internal/disclose-version')); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js index 40c0907e38..e34bfc0123 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.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' || !!state.options.customRenderer; const expression = tree ? state.template.as_tree() : state.template.as_html(); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 0579d80b74..ec74c3ed27 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -47,7 +47,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) && !context.state.options.customRenderer; // 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 diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index fce3f62c5c..325d87ef6a 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -132,6 +132,10 @@ export interface CompileOptions extends ModuleCompileOptions { * @since 5.33 */ fragments?: 'html' | 'tree'; + /** + * 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; /** * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage. * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage. diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 3c1e3e772c..ea2f2bd9f7 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -85,9 +85,10 @@ export namespace AST { preserveWhitespace?: boolean; namespace?: Namespace; css?: 'injected'; + customRenderer?: string; customElement?: { tag?: string; - shadow?: 'open' | 'none' | ObjectExpression | undefined; + shadow?: 'open' | 'none'; props?: Record< string, { diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 223676d22f..d4679ff8d7 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -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]) })) diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js index a94a553311..e0215b4eff 100644 --- a/packages/svelte/src/compiler/validate-options.js +++ b/packages/svelte/src/compiler/validate-options.js @@ -83,6 +83,8 @@ const component_options = { immutable: deprecate(w.options_deprecated_immutable, boolean(false)), + customRenderer: string(undefined), + legacy: removed( 'The legacy option has been removed. If you are using this because of legacy.componentApi, use compatibility.componentApi instead' ), diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js new file mode 100644 index 0000000000..29f8bb19a6 --- /dev/null +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -0,0 +1,28 @@ +import { branch, effect_root } from "../reactivity/effects"; +import { push_renderer } from "./state"; + +/** + * @param {*} renderer + * @returns + */ +export function createRenderer(renderer) { + return { + ...renderer, + /** + * @param {*} Component + * @param {*} options + */ + render(Component, { target, props }) { + var cleanup = push_renderer(renderer); + const unmount = effect_root(() => { + var anchor = renderer.createComment(''); + renderer.insert(target, anchor, null); + branch(() => { + Component(anchor, props); + }); + }); + cleanup(); + return unmount; + } + }; +} \ No newline at end of file diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js new file mode 100644 index 0000000000..c1075ed454 --- /dev/null +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -0,0 +1,20 @@ +/** + * @type {any} + */ +let renderer = null; + +export function get_renderer() { + return renderer; +} + +/** + * + * @param {any} $renderer + */ +export function push_renderer($renderer) { + let old_renderer = renderer; + renderer = $renderer; + return () => { + renderer = old_renderer; + }; +} \ No newline at end of file diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 988998d067..97f891058d 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -181,3 +181,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 } from "./custom-renderer/state.js" \ No newline at end of file diff --git a/packages/svelte/src/renderer/index.js b/packages/svelte/src/renderer/index.js new file mode 100644 index 0000000000..3377f7f1cb --- /dev/null +++ b/packages/svelte/src/renderer/index.js @@ -0,0 +1 @@ +export { createRenderer } from '../internal/client/custom-renderer/index.js'; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 31ef9110df..9305513278 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1089,6 +1089,10 @@ declare module 'svelte/compiler' { * @since 5.33 */ fragments?: 'html' | 'tree'; + /** + * 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; /** * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage. * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage. @@ -1240,9 +1244,10 @@ declare module 'svelte/compiler' { preserveWhitespace?: boolean; namespace?: Namespace; css?: 'injected'; + customRenderer?: string; customElement?: { tag?: string; - shadow?: 'open' | 'none' | ObjectExpression | undefined; + shadow?: 'open' | 'none'; props?: Record< string, { @@ -2557,6 +2562,12 @@ declare module 'svelte/reactivity/window' { export {}; } +declare module 'svelte/renderer' { + export function createRenderer(renderer: any): any; + + export {}; +} + declare module 'svelte/server' { import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte'; /** @@ -3065,6 +3076,10 @@ declare module 'svelte/types/compiler/interfaces' { * @since 5.33 */ fragments?: 'html' | 'tree'; + /** + * 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; /** * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage. * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage. From 23e52db4e04911efd585973748b8d82f77dd75f1 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Fri, 27 Mar 2026 10:34:17 +0100 Subject: [PATCH 02/88] chore: better types --- .../internal/client/custom-renderer/index.js | 35 ++++++++++++++++--- .../internal/client/custom-renderer/state.js | 14 ++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 29f8bb19a6..b7ade121f8 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -1,9 +1,34 @@ -import { branch, effect_root } from "../reactivity/effects"; -import { push_renderer } from "./state"; +import { branch, effect_root } from '../reactivity/effects'; +import { push_renderer } from './state'; /** - * @param {*} renderer - * @returns + * @template [TFragment=any] + * @template [TElement=any] + * @template [TTextNode=any] + * @template [TComment=any] + * @template [TNode=TElement | TTextNode | TComment | TFragment] + * @typedef {Object} Renderer + * @property {()=>TFragment} createFragment + * @property {(name: string)=>TElement} createElement + * @property {(data: string)=>TTextNode} createTextNode + * @property {(element: TElement, key: string, value: any)=>void} setAttribute + * @property {(node: TNode, text: string)=>void} setText + * @property {(data: string)=>TComment} createComment + * @property {(element: TNode)=>TNode} getFirstChild + * @property {(element: TNode)=>TNode} getLastChild + * @property {(element: TNode)=>TNode} getNextSibling + * @property {(parent: TNode, element: TNode, anchor: TNode | null)=>void} insert + * @property {(node: TNode)=>void} remove + * @property {(element: TNode)=>TNode} getParent + */ + +/** + * @template [const TFragment=unknown] + * @template [const TElement=unknown] + * @template [const TTextNode=unknown] + * @template [const TComment=unknown] + * @param {Renderer} renderer + * @returns {Renderer & { render: (Component: any, options: { target: TNode, props?: any }) => () => void }} */ export function createRenderer(renderer) { return { @@ -25,4 +50,4 @@ export function createRenderer(renderer) { return unmount; } }; -} \ No newline at end of file +} diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js index c1075ed454..9eee42e14a 100644 --- a/packages/svelte/src/internal/client/custom-renderer/state.js +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -1,15 +1,23 @@ /** - * @type {any} + * @import { Renderer } from "."; + */ + +/** + * @type {Renderer | null} */ let renderer = null; +/** + * + * @returns {Renderer | null} + */ export function get_renderer() { return renderer; } /** * - * @param {any} $renderer + * @param {Renderer} $renderer */ export function push_renderer($renderer) { let old_renderer = renderer; @@ -17,4 +25,4 @@ export function push_renderer($renderer) { return () => { renderer = old_renderer; }; -} \ No newline at end of file +} From 101d1cebdcc96afc0f721df9a652f183ba790f82 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sat, 28 Mar 2026 15:16:31 +0100 Subject: [PATCH 03/88] chore: better types and more complete interface --- .../internal/client/custom-renderer/index.js | 36 +++++--- packages/svelte/types/index.d.ts | 89 ++++++++++++++++++- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index b7ade121f8..e6ea2e29b5 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -6,20 +6,28 @@ import { push_renderer } from './state'; * @template [TElement=any] * @template [TTextNode=any] * @template [TComment=any] - * @template [TNode=TElement | TTextNode | TComment | TFragment] + * @template [TNode=TFragment | TElement | TTextNode | TComment] * @typedef {Object} Renderer - * @property {()=>TFragment} createFragment - * @property {(name: string)=>TElement} createElement - * @property {(data: string)=>TTextNode} createTextNode - * @property {(element: TElement, key: string, value: any)=>void} setAttribute - * @property {(node: TNode, text: string)=>void} setText - * @property {(data: string)=>TComment} createComment - * @property {(element: TNode)=>TNode} getFirstChild - * @property {(element: TNode)=>TNode} getLastChild - * @property {(element: TNode)=>TNode} getNextSibling - * @property {(parent: TNode, element: TNode, anchor: TNode | null)=>void} insert - * @property {(node: TNode)=>void} remove - * @property {(element: TNode)=>TNode} getParent + * @property {()=>TFragment} createFragment - Creates a fragment, a container for multiple nodes. Inserting a fragment should insert all of it's children. + * @property {(name: string)=>TElement} createElement - Creates an element with the given name. + * @property {(data: string)=>TTextNode} createTextNode - Creates a text node with the given data. + * @property {(data: string)=>TComment} createComment - 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 + * @property {(node: TNode)=> "fragment" | "element" | "text" | "comment"} nodeType - Should return the type of the node in string form ("fragment", "element", "text", "comment"). + * @property {(node: TNode)=>string | null} getNodeValue - Return the value of the node...this should be the text value of a text node, the data value of a comment, null for elements and fragments + * @property {(element: TElement, name: string)=>string | null} getAttribute - Return the value of the attribute with the given name on the element, or null if it doesn't exist + * @property {(element: TElement, key: string, value: any)=>void} setAttribute - Set the attribute with the given name and value on the element + * @property {(element: TElement, name: string)=>void} removeAttribute - Remove the attribute with the given name from the element + * @property {(element: TElement, name: string)=>boolean} hasAttribute - Return true if the element has an attribute with the given name + * @property {(node: TNode, text: string)=>void} setText - Set the text content of the node to the given value. This should work for both text nodes and elements (setting text content on an element should replace all of it's children with a single text node) + * @property {(element: TElement | TFragment)=>TNode} getFirstChild - Return the first child of the element, or null if it has no children. This should work for both elements and fragments + * @property {(element: TElement | TFragment)=>TNode} getLastChild - Return the last child of the element, or null if it has no children. This should work for both elements and fragments + * @property {(element: TNode)=>TNode} getNextSibling - Return the next sibling of the node, or null if it has no next sibling + * @property {(parent: TElement | TFragment, element: TNode, anchor: TNode | null)=>void} insert - Insert the element into the parent before the anchor (if the anchor is null, insert at the end). This should work for both elements and fragments as parents + * @property {(node: TNode)=>void} remove - Remove the node from the tree + * @property {(element: TNode)=>TNode} getParent - Return the parent of the element, or null if it has no parent + * @property {(node: TNode, deep: boolean)=>TNode} cloneNode - Return a clone of the node. If deep is true, all of the node's children should also be cloned + * @property {(target: TNode, type: string, handler: any, options?: any)=>void} addEventListener - Add an event listener of the given type and handler to the target node, with optional options + * @property {(target: TNode, type: string, handler: any, options?: any)=>void} removeEventListener - Remove an event listener of the given type and handler from the target node, with optional options */ /** @@ -28,7 +36,7 @@ import { push_renderer } from './state'; * @template [const TTextNode=unknown] * @template [const TComment=unknown] * @param {Renderer} renderer - * @returns {Renderer & { render: (Component: any, options: { target: TNode, props?: any }) => () => void }} + * @returns {Renderer & { render: (Component: any, options: { target: TFragment | TElement | TTextNode | TComment, props?: any }) => () => void }} */ export function createRenderer(renderer) { return { diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 51b9d079f4..4b73972af1 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2568,7 +2568,94 @@ declare module 'svelte/reactivity/window' { } declare module 'svelte/renderer' { - export function createRenderer(renderer: any): any; + export function createRenderer(renderer: Renderer): Renderer & { + render: (Component: any, options: { + target: TFragment | TElement | TTextNode | TComment; + props?: any; + }) => () => void; + }; + export type Renderer = { + /** + * - Creates a fragment, a container for multiple nodes. Inserting a fragment should insert all of it's 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 ("fragment", "element", "text", "comment"). + */ + nodeType: (node: TNode) => "fragment" | "element" | "text" | "comment"; + /** + * - Return the value of the node...this should be the text value of a text node, the data value of a comment, null for elements and fragments + */ + getNodeValue: (node: TNode) => 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 (setting text content on an element should replace all of it's children with a single text node) + */ + setText: (node: TNode, text: string) => void; + /** + * - Return the first child of the element, or null if it has no children. This should work for both elements and fragments + */ + getFirstChild: (element: TElement | TFragment) => TNode; + /** + * - Return the last child of the element, or null if it has no children. This should work for both elements and fragments + */ + getLastChild: (element: TElement | TFragment) => TNode; + /** + * - Return the next sibling of the node, or null if it has no next sibling + */ + getNextSibling: (element: TNode) => TNode; + /** + * - Insert the element into the parent before the anchor (if the anchor is null, insert at the end). This should work for both elements and fragments as parents + */ + insert: (parent: TElement | TFragment, element: TNode, anchor: TNode | null) => void; + /** + * - Remove the node from the tree + */ + remove: (node: TNode) => void; + /** + * - Return the parent of the element, or null if it has no parent + */ + getParent: (element: TNode) => TNode; + /** + * - Return a clone of the node. If deep is true, all of the node's children should also be cloned + */ + cloneNode: (node: TNode, deep: boolean) => TNode; + /** + * - Add an event listener of the given type and handler to the target node, with optional options + */ + addEventListener: (target: TNode, type: string, handler: any, options?: any) => void; + /** + * - Remove an event listener of the given type and handler from the target node, with optional options + */ + removeEventListener: (target: TNode, type: string, handler: any, options?: any) => void; + }; export {}; } From ac40179686b72deb6a5de6bdef0ae5d12e147777 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sat, 28 Mar 2026 15:16:54 +0100 Subject: [PATCH 04/88] chore: remove getter --- .../src/internal/client/custom-renderer/state.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js index 9eee42e14a..2f0bf8b174 100644 --- a/packages/svelte/src/internal/client/custom-renderer/state.js +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -5,15 +5,7 @@ /** * @type {Renderer | null} */ -let renderer = null; - -/** - * - * @returns {Renderer | null} - */ -export function get_renderer() { - return renderer; -} +export let renderer = null; /** * From 0a0212c86ecc57ab95f01870329340b3c94188b7 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sat, 28 Mar 2026 15:19:27 +0100 Subject: [PATCH 05/88] chore: new operations functions to replace direct DOM access --- .../svelte/src/internal/client/constants.js | 8 + .../src/internal/client/dom/operations.js | 489 +++++++++++++++++- 2 files changed, 486 insertions(+), 11 deletions(-) diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index df96f4899b..032b6c3e2b 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -69,6 +69,7 @@ export const STALE_REACTION = new (class StaleReactionError extends Error { message = 'The reaction that called `getAbortSignal()` was re-run or destroyed'; })(); +// TODO: DOM access export const IS_XHTML = // We gotta write it like this because after downleveling the pure comment may end up in the wrong location !!globalThis.document?.contentType && @@ -77,3 +78,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 +}); diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 4036aa2d61..52c68291ca 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -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 { 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 (renderer) return /** @type {Text} */ (renderer.createTextNode(value)); return document.createTextNode(value); } @@ -87,6 +94,7 @@ export function create_text(value = '') { */ /*@__NO_SIDE_EFFECTS__*/ export function get_first_child(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getFirstChild(node)); return /** @type {TemplateNode | null} */ (first_child_getter.call(node)); } @@ -96,6 +104,7 @@ export function get_first_child(node) { */ /*@__NO_SIDE_EFFECTS__*/ export function get_next_sibling(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getNextSibling(node)); return /** @type {TemplateNode | null} */ (next_sibling_getter.call(node)); } @@ -115,10 +124,10 @@ export function child(node, is_text) { // Child can be null if we have an element with a single child, like `

{text}

`, 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 +151,8 @@ 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); + // TODO RENDERER: can we find another way to not have an `isComment` on the renderer? + if (is_comment(first) && get_node_value(first) === '') return get_next_sibling(first); return first; } @@ -150,10 +160,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 +197,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 +224,15 @@ export function sibling(node, count = 1, is_text = false) { * @returns {void} */ export function clear_text_content(node) { + if (renderer) { + var child = renderer.getFirstChild(node); + while (child !== null) { + var next = renderer.getNextSibling(child); + renderer.remove(child); + child = next; + } + return; + } node.textContent = ''; } @@ -239,6 +258,10 @@ export function should_defer_append() { * @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ export function create_element(tag, namespace, is) { + if (renderer) + return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ ( + 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 +269,7 @@ export function create_element(tag, namespace, is) { } export function create_fragment() { + if (renderer) return /** @type {DocumentFragment} */ (renderer.createFragment()); return document.createDocumentFragment(); } @@ -254,6 +278,7 @@ export function create_fragment() { * @returns */ export function create_comment(data = '') { + if (renderer) return /** @type {Comment} */ (renderer.createComment(data)); return document.createComment(data); } @@ -264,6 +289,10 @@ export function create_comment(data = '') { * @returns */ export function set_attribute(element, key, value = '') { + if (renderer) { + renderer.setAttribute(element, key, value); + return; + } if (key.startsWith('xlink:')) { element.setAttributeNS('http://www.w3.org/1999/xlink', key, value); return; @@ -277,13 +306,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 (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 +323,438 @@ export function merge_text_nodes(text) { next = text.nextSibling; } } + +/** + * @param {TemplateNode | null} node + * @returns {node is Comment} + */ +function is_comment(node) { + if (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 (renderer) { + const type = 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 (renderer) { + return ''; + } + return node?.nodeName; +} + +/** + * @param {Node} node + * @returns {TemplateNode | null} + */ +export function get_last_child(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getLastChild(node)); + return /** @type {TemplateNode | null} */ (node.lastChild); +} + +/** + * @param {Node} node + * @returns {TemplateNode | null} + */ +export function get_parent_node(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getParent(node)); + return /** @type {TemplateNode | null} */ (node.parentNode); +} + +/** + * @param {Node} parent + * @param {Node} child + * @returns {Node} + */ +export function append_child(parent, child) { + if (renderer) { + 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 (renderer) { + var parent = renderer.getParent(ref_node); + 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 (renderer) { + var parent = renderer.getParent(ref_node); + var next = renderer.getNextSibling(ref_node); + renderer.insert(parent, new_node, next); + return; + } + ref_node.after(new_node); +} + +/** + * @param {ChildNode} node + */ +export function remove_node(node) { + if (renderer) { + renderer.remove(node); + return; + } + node.remove(); +} + +/** + * @param {Node} parent + * @param {ChildNode} child + * @returns {ChildNode} + */ +export function remove_child(parent, child) { + if (renderer) { + 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 (renderer) { + var parent = renderer.getParent(old_node); + renderer.insert(parent, new_node, old_node); + renderer.remove(old_node); + return; + } + old_node.replaceWith(new_node); +} + +/** + * @param {Node} node + * @param {string} value + */ +export function set_text_content(node, value) { + if (renderer) { + renderer.setText(node, value); + return; + } + node.textContent = value; +} + +/** + * @param {Node} node + * @param {string} value + */ +export function set_node_value(node, value) { + if (renderer) { + renderer.setText(node, value); + return; + } + node.nodeValue = value; +} + +// --- Helpers for style attribute string manipulation (custom renderer) --- + +/** + * @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 (renderer) return renderer.getNodeValue(node); + return node.nodeValue; +} + +/** + * @param {Element} element + * @param {string} name + * @returns {string | null} + */ +export function get_attribute(element, name) { + if (renderer) return renderer.getAttribute(element, name); + return element.getAttribute(name); +} + +/** + * @param {Element} element + * @param {string} name + */ +export function remove_attribute(element, name) { + if (renderer) { + renderer.removeAttribute(element, name); + return; + } + element.removeAttribute(name); +} + +/** + * @param {Element} element + * @param {string} name + * @returns {boolean} + */ +export function has_attribute(element, name) { + // TODO RENDERER: could be worth removing and just using get? + + if (renderer) return renderer.hasAttribute(element, name); + return element.hasAttribute(name); +} + +/** + * @param {Element} element + * @param {string} value + */ +export function set_inner_html(element, value) { + if (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 (renderer) return renderer.cloneNode(node, deep); + return node.cloneNode(deep); +} + +/** + * @param {Node} node + * @param {boolean} deep + * @returns {Node} + */ +export function import_node(node, deep) { + if (renderer) return renderer.cloneNode(node, deep); + 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 (renderer) { + 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 (renderer) { + 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 (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 (renderer) { + var style = renderer.getAttribute(element, 'style') || ''; + var updated = set_style_property_in_string(style, property, value, priority); + 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 (renderer) { + var style = renderer.getAttribute(element, 'style') || ''; + var updated = remove_style_property_in_string(style, property); + renderer.setAttribute(element, 'style', updated); + return; + } + element.style.removeProperty(property); +} + +/** + * @param {HTMLElement} element + * @param {string} value + */ +export function set_css_text(element, value) { + if (renderer) { + 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 (renderer) { + const classes = element.getAttribute('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); + } + } + element.setAttribute('class', classes.join(' ')); + return; + } + element.classList.toggle(name, force); +} + +/** + * @param {Element} element + * @param {string} selector + */ +export function query_selector(element, selector) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('querySelector is not supported with custom renderers'); + } + return element.querySelector(selector); +} + +/** + * @param {Element | DocumentFragment} element + * @param {string} selector + */ +export function query_selector_all(element, selector) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('querySelectorAll is not supported with custom renderers'); + } + return element.querySelectorAll(selector); +} + +/** + * @param {Node} node + * @returns {Node} + */ +export function get_root_node(node) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('getRootNode is not supported with custom renderers'); + } + return node.getRootNode(); +} + +/** + * @param {HTMLElement} element + */ +export function focus(element) { + if (renderer) { + // doesn't make sense with custom renderers + throw new Error('focus is not supported with custom renderers'); + } + element.focus(); +} From 4eb2c0bd807c9fe2ac2501ec30ff4901ae3f70bf Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sat, 28 Mar 2026 16:00:26 +0100 Subject: [PATCH 06/88] fix: import correctly --- packages/svelte/src/internal/client/custom-renderer/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index e6ea2e29b5..c7a2d95772 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -1,5 +1,5 @@ -import { branch, effect_root } from '../reactivity/effects'; -import { push_renderer } from './state'; +import { branch, effect_root } from '../reactivity/effects.js'; +import { push_renderer } from './state.js'; /** * @template [TFragment=any] From 6d13e5a1d097a284bcd4e47327e0638607fcc7ea Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sat, 28 Mar 2026 16:02:07 +0100 Subject: [PATCH 07/88] fix: store current renderer in effect and push it before executing it --- .../svelte/src/internal/client/reactivity/effects.js | 11 +++++++---- .../svelte/src/internal/client/reactivity/types.d.ts | 3 +++ packages/svelte/src/internal/client/runtime.js | 5 +++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 54c8a17d79..6139e22438 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -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 } 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 { 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: renderer }; if (DEV) { @@ -560,6 +562,7 @@ export function destroy_effect(effect, remove_dom = true) { effect.nodes = effect.ac = effect.b = + effect.r = null; } @@ -573,7 +576,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,7 +737,7 @@ 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; } } diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 8477917991..025527e897 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -7,6 +7,7 @@ import type { TransitionManager } from '#client'; import type { Boundary } from '../dom/blocks/boundary'; +import type { Renderer } from '../custom-renderer/index'; 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 */ diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 906d68fbf0..3d9f1d684b 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -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; @@ -442,6 +443,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); @@ -476,6 +479,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); From a59efbf610c19464b08eb8a15490d78dbc24eddf Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 08:40:04 +0200 Subject: [PATCH 08/88] fix: wrap component in boundary in `render` --- .../svelte/src/internal/client/custom-renderer/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index c7a2d95772..0cf751a73c 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -1,3 +1,4 @@ +import { boundary } from '../dom/blocks/boundary.js'; import { branch, effect_root } from '../reactivity/effects.js'; import { push_renderer } from './state.js'; @@ -50,8 +51,10 @@ export function createRenderer(renderer) { const unmount = effect_root(() => { var anchor = renderer.createComment(''); renderer.insert(target, anchor, null); - branch(() => { - Component(anchor, props); + boundary(/** @type {*} */ (anchor), { pending: () => {} }, (anchor) => { + branch(() => { + Component(anchor, props); + }); }); }); cleanup(); From 432c0235c941e9c19b10377e363a2580638075a9 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 08:43:11 +0200 Subject: [PATCH 09/88] fix: push and pop renderer when needed --- .../internal/client/custom-renderer/state.js | 7 +++ .../internal/client/dom/blocks/boundary.js | 38 ++++++++++--- .../internal/client/dom/blocks/branches.js | 37 +++++++++--- .../src/internal/client/dom/blocks/each.js | 57 +++++++++++++++---- .../src/internal/client/reactivity/async.js | 5 ++ .../src/internal/client/reactivity/effects.js | 10 +++- 6 files changed, 127 insertions(+), 27 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js index 2f0bf8b174..90e8f21372 100644 --- a/packages/svelte/src/internal/client/custom-renderer/state.js +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -7,6 +7,13 @@ */ export let renderer = null; +/** + * @param {Renderer | null} $renderer + */ +export function set_renderer($renderer) { + renderer = $renderer; +} + /** * * @param {Renderer} $renderer diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b440bb3ba4..22ec1e7830 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -39,9 +39,16 @@ import { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js 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: - 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?.(); } } } diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index a8096e0a58..ee0efd43f5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -1,4 +1,5 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Renderer } from '../../custom-renderer' */ import { Batch, current_batch } from '../../reactivity/batch.js'; import { branch, @@ -8,7 +9,16 @@ import { resume_effect } from '../../reactivity/effects.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 { push_renderer, renderer as current_renderer } from '../../custom-renderer/state.js'; /** * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch @@ -59,6 +69,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 @@ -66,6 +84,7 @@ export class BranchManager { constructor(anchor, transition = true) { this.anchor = anchor; this.#transition = transition; + this.#renderer = current_renderer; } /** @@ -75,6 +94,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); @@ -92,10 +113,10 @@ export class BranchManager { this.#offscreen.delete(key); // 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; } } @@ -129,10 +150,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 { @@ -150,6 +171,8 @@ export class BranchManager { on_destroy(); } } + + pop_renderer?.(); }; /** @@ -179,10 +202,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)), diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 3acdf80d84..bee21178ae 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -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, @@ -43,6 +49,8 @@ import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; import * as e from '../../errors.js'; +import { push_renderer, 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` @@ -107,10 +115,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(); } @@ -152,7 +160,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); @@ -163,6 +171,26 @@ function destroy_effects(state, to_destroy, remove_dom = true) { /** @type {TemplateNode} */ var offscreen_anchor; +/** + * 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() { + if (offscreen_anchor !== undefined) return offscreen_anchor; + + offscreen_anchor = create_text(); + + if (renderer) { + var fragment = create_fragment(); + append_child(fragment, offscreen_anchor); + } + + return offscreen_anchor; +} + /** * @template V * @param {Element | Comment} node The next sibling node, or the parent node if this is a 'controlled' block @@ -181,12 +209,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 captured_renderer = 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) { @@ -221,6 +254,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return; } + var pop_renderer = captured_renderer !== null ? push_renderer(captured_renderer) : null; + state.pending.delete(batch); state.fallback = fallback; @@ -243,6 +278,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); } } + + pop_renderer?.(); } /** @@ -279,8 +316,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 @@ -313,7 +350,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, @@ -336,7 +373,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; } } @@ -708,7 +745,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; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 7b0b108e4c..fe8e63084b 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -9,6 +9,7 @@ import { set_dev_stack } from '../context.js'; import { Boundary } from '../dom/blocks/boundary.js'; +import { 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 = 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 @@ -221,6 +225,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) { diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 6139e22438..a33f03fd96 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -46,7 +46,7 @@ import { Batch, collected_effects } 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 { renderer } from '../custom-renderer/state.js'; +import { push_renderer, renderer } from '../custom-renderer/state.js'; /** * @param {'$effect' | '$effect.pre' | '$inspect'} rune @@ -514,6 +514,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 && @@ -564,6 +566,8 @@ export function destroy_effect(effect, remove_dom = true) { effect.b = effect.r = null; + + pop_renderer?.(); } /** @@ -729,6 +733,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; @@ -740,4 +746,6 @@ export function move_effect(effect, fragment) { append_child(fragment, node); node = next; } + + pop_renderer?.(); } From 9ae3faa09a8b1d2b7921ab3d3dbcd1094dab462f Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 08:51:36 +0200 Subject: [PATCH 10/88] chore: centralize methods in `operations.js` --- packages/svelte/src/internal/client/dom/operations.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 52c68291ca..7b592b84f9 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -351,6 +351,8 @@ export function node_type(node) { export function node_name(node) { if (node == null) return undefined; if (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; @@ -589,7 +591,9 @@ export function set_inner_html(element, value) { * @returns {Node} */ export function clone_node(node, deep) { - if (renderer) return renderer.cloneNode(node, deep); + if (renderer) { + throw new Error('cloneNode is not supported with custom renderers'); + } return node.cloneNode(deep); } @@ -599,7 +603,9 @@ export function clone_node(node, deep) { * @returns {Node} */ export function import_node(node, deep) { - if (renderer) return renderer.cloneNode(node, deep); + if (renderer) { + throw new Error('importNode is not supported with custom renderers'); + } return document.importNode(node, deep); } From c459824a883eca3b8770ae2b671d3e36262b960e Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 08:52:13 +0200 Subject: [PATCH 11/88] fix: don't clone node if a renderer is available --- .../src/internal/client/dom/template.js | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index a5d0405d0a..d7c57765a4 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -10,7 +10,18 @@ import { create_fragment, create_comment, set_attribute, - merge_text_nodes + merge_text_nodes, + get_last_child, + import_node, + clone_node, + append_child, + insert_before, + set_text_content, + replace_with, + node_type, + query_selector_all, + get_node_value, + node_name } from './operations.js'; import { create_fragment_from_html } from './reconciler.js'; import { active_effect } from '../runtime.js'; @@ -29,6 +40,7 @@ import { REACTION_RAN, TEXT_NODE } from '#client/constants'; +import { renderer } from '../custom-renderer/state.js'; const TEMPLATE_TAG = IS_XHTML ? 'template' : 'TEMPLATE'; const SCRIPT_TAG = IS_XHTML ? 'script' : 'SCRIPT'; @@ -75,12 +87,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 +134,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 +185,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 `['// ']` 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 +207,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 + ? // TODO: DOM access + /** @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 +243,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 || renderer != null) { const ns = (flags & TEMPLATE_USE_SVG) !== 0 ? NAMESPACE_SVG @@ -242,12 +259,16 @@ export function from_tree(structure, flags) { } var clone = /** @type {TemplateNode} */ ( - use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true) + 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 { @@ -275,31 +296,34 @@ function run_scripts(node) { // 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 + // TODO RENDERER: figure out what to do here + node_name(node) === SCRIPT_TAG ? [/** @type {HTMLScriptElement} */ (node)] - : node.querySelectorAll('script'); + : query_selector_all(node, 'script'); const effect = /** @type {Effect & { nodes: EffectNodes }} */ (active_effect); for (const script of scripts) { const clone = create_element('script'); + // TODO: DOM access for (var attribute of script.attributes) { - clone.setAttribute(attribute.name, attribute.value); + set_attribute(clone, attribute.name, attribute.value); } - clone.textContent = script.textContent; + // TODO: DOM access + 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 +341,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 +363,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,24 +400,26 @@ 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; } + // TODO RENDERER: figure out what to do here // @ts-expect-error This way we ensure the id is unique even across Svelte runtimes (window.__svelte ??= {}).uid ??= 1; From cf874c0ab9bf53999438c6bcf04c16adea1d4bbb Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 08:53:56 +0200 Subject: [PATCH 12/88] fix: generate types --- packages/svelte/types/index.d.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 4b73972af1..a3a02eddd3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2643,10 +2643,6 @@ declare module 'svelte/renderer' { * - Return the parent of the element, or null if it has no parent */ getParent: (element: TNode) => TNode; - /** - * - Return a clone of the node. If deep is true, all of the node's children should also be cloned - */ - cloneNode: (node: TNode, deep: boolean) => TNode; /** * - Add an event listener of the given type and handler to the target node, with optional options */ From bd70ab621c237d00597d409fff2ad84ec8bc98a2 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 10:33:36 +0200 Subject: [PATCH 13/88] fix: events dom access --- .../internal/client/dom/elements/events.js | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index e598a78949..23022170eb 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -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 { 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); } }); } @@ -79,10 +88,10 @@ export function create_event(event_name, dom, handler, options = {}) { 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 +111,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 +128,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. + 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 +182,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 +272,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; From 49cff549c70edbdcc05866cd4fb111fa4a4646e0 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 10:34:02 +0200 Subject: [PATCH 14/88] chore: initial test suite --- .../svelte/tests/custom-renderers/renderer.js | 256 ++++++++++++++++++ .../samples/attributes/_config.js | 25 ++ .../samples/attributes/main.svelte | 7 + .../samples/basic-element/_config.js | 5 + .../samples/basic-element/main.svelte | 1 + .../samples/conditional-rendering/_config.js | 24 ++ .../samples/conditional-rendering/main.svelte | 11 + .../samples/each-block/_config.js | 5 + .../samples/each-block/main.svelte | 9 + .../samples/event-handler/_config.js | 25 ++ .../samples/event-handler/main.svelte | 10 + .../samples/nested-components/Child.svelte | 6 + .../samples/nested-components/_config.js | 5 + .../samples/nested-components/main.svelte | 7 + .../samples/reactive-state/_config.js | 18 ++ .../samples/reactive-state/main.svelte | 7 + .../samples/snippet/_config.js | 5 + .../samples/snippet/main.svelte | 12 + .../samples/text-expression/_config.js | 5 + .../samples/text-expression/main.svelte | 6 + .../svelte/tests/custom-renderers/shared.ts | 188 +++++++++++++ .../svelte/tests/custom-renderers/test.ts | 8 + 22 files changed, 645 insertions(+) create mode 100644 packages/svelte/tests/custom-renderers/renderer.js create mode 100644 packages/svelte/tests/custom-renderers/samples/attributes/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/attributes/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/basic-element/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/each-block/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/each-block/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/snippet/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/snippet/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/shared.ts create mode 100644 packages/svelte/tests/custom-renderers/test.ts diff --git a/packages/svelte/tests/custom-renderers/renderer.js b/packages/svelte/tests/custom-renderers/renderer.js new file mode 100644 index 0000000000..3b8b207c9a --- /dev/null +++ b/packages/svelte/tests/custom-renderers/renderer.js @@ -0,0 +1,256 @@ +/** + * 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, children: ObjNode[], listeners: Record>, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjElement + * @typedef {{ type: 'text', value: string, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjText + * @typedef {{ type: 'comment', value: string, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjComment + * @typedef {{ type: 'fragment', children: ObjNode[], parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: 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) { + const last = children[children.length - 1] ?? null; + if (last) { + last.next_sibling = node; + node.prev_sibling = last; + } + node.next_sibling = null; + children.push(node); + } else { + const idx = children.indexOf(anchor); + if (idx === -1) throw new Error('Anchor not found in parent'); + + const prev = children[idx - 1] ?? null; + if (prev) { + prev.next_sibling = node; + node.prev_sibling = prev; + } + node.next_sibling = anchor; + anchor.prev_sibling = node; + 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; + + const prev = children[idx - 1] ?? null; + const next = children[idx + 1] ?? null; + if (prev) prev.next_sibling = next; + if (next) next.prev_sibling = prev; + + node.prev_sibling = null; + node.next_sibling = null; + node.parent = null; + + children.splice(idx, 1); +} + +const renderer = createRenderer({ + createFragment() { + return /** @type {ObjFragment} */ ({ + type: 'fragment', + children: [], + parent: null, + next_sibling: null, + prev_sibling: null + }); + }, + + createElement(name) { + return /** @type {ObjElement} */ ({ + type: 'element', + name, + attributes: {}, + children: [], + listeners: {}, + parent: null, + next_sibling: null, + prev_sibling: null + }); + }, + + createTextNode(data) { + return /** @type {ObjText} */ ({ + type: 'text', + value: data, + parent: null, + next_sibling: null, + prev_sibling: null + }); + }, + + createComment(data) { + return /** @type {ObjComment} */ ({ + type: 'comment', + value: data, + parent: null, + next_sibling: null, + prev_sibling: null + }); + }, + + 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) { + return node.next_sibling ?? 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 {ObjElement} + */ +export function create_root() { + return renderer.createElement('root'); +} + +/** + * 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}`; + } + default: + return ''; + } +} + +export default renderer; diff --git a/packages/svelte/tests/custom-renderers/samples/attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js new file mode 100644 index 0000000000..318378d0d8 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js @@ -0,0 +1,25 @@ +import { test } from '../../test'; + +export default test({ + test({ assert, target, serialize }) { + const html = serialize(target); + assert.equal( + html, + '
colored
' + ); + + // 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'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte b/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte new file mode 100644 index 0000000000..4f9a55c096 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte @@ -0,0 +1,7 @@ + + +
+ colored +
diff --git a/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js b/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js new file mode 100644 index 0000000000..8de48d5f2d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte b/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte new file mode 100644 index 0000000000..302a01f335 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte @@ -0,0 +1 @@ +

hello

diff --git a/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js new file mode 100644 index 0000000000..9538a019c3 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js @@ -0,0 +1,24 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

visible

', + test({ assert, target, serialize, dispatch_event }) { + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + dispatch_event(button, 'click'); + flushSync(); + + let html = serialize(target); + assert.equal(html, '

hidden

'); + + dispatch_event(button, 'click'); + flushSync(); + + html = serialize(target); + assert.equal(html, '

visible

'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte new file mode 100644 index 0000000000..86e5dd8577 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte @@ -0,0 +1,11 @@ + + +{#if visible} +

visible

+{:else} +

hidden

+{/if} + + diff --git a/packages/svelte/tests/custom-renderers/samples/each-block/_config.js b/packages/svelte/tests/custom-renderers/samples/each-block/_config.js new file mode 100644 index 0000000000..05e41069c8 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/each-block/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
  • a
  • b
  • c
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte b/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte new file mode 100644 index 0000000000..6b93690b55 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte @@ -0,0 +1,9 @@ + + +
    + {#each items as item} +
  • {item}
  • + {/each} +
diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js b/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js new file mode 100644 index 0000000000..013a983464 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

0

', + test({ assert, target, serialize, dispatch_event }) { + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + dispatch_event(button, 'click'); + flushSync(); + + const html = serialize(target); + assert.equal(html, '

1

'); + + dispatch_event(button, 'click'); + dispatch_event(button, 'click'); + flushSync(); + + const html2 = serialize(target); + assert.equal(html2, '

3

'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte b/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte new file mode 100644 index 0000000000..1df8f7f799 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte @@ -0,0 +1,10 @@ + + + +

{count}

diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte b/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte new file mode 100644 index 0000000000..7253d3efb2 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte @@ -0,0 +1,6 @@ + + +{message} diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js b/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js new file mode 100644 index 0000000000..49d4f450ec --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
hello from child
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte b/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte new file mode 100644 index 0000000000..c48bc7dac7 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js new file mode 100644 index 0000000000..82a8a02a64 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '', + test({ assert, target, serialize, dispatch_event }) { + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + dispatch_event(button, 'click'); + flushSync(); + + const html = serialize(target); + assert.equal(html, ''); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte new file mode 100644 index 0000000000..ce9ad28ec5 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/_config.js b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js new file mode 100644 index 0000000000..fd2a9d2db5 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
hello default
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte new file mode 100644 index 0000000000..46e069c215 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte @@ -0,0 +1,12 @@ + + +{#snippet greeting(name)} + hello {name} +{/snippet} + +
+ {@render greeting(label)} +
diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js new file mode 100644 index 0000000000..68e35f6a66 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello world

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte new file mode 100644 index 0000000000..afca058e70 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte @@ -0,0 +1,6 @@ + + +

hello {name}

diff --git a/packages/svelte/tests/custom-renderers/shared.ts b/packages/svelte/tests/custom-renderers/shared.ts new file mode 100644 index 0000000000..fa7e1fb0e9 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/shared.ts @@ -0,0 +1,188 @@ +import * as path from 'node:path'; +import { setImmediate } from 'node:timers/promises'; +import { assert } from 'vitest'; +import { compile_directory } from '../helpers.js'; +import { suite_with_variants, type BaseTest } from '../suite.js'; +import type { CompileOptions } from '#compiler'; +import renderer, { create_root, serialize, dispatch_event } from './renderer.js'; + +export interface CustomRendererTest extends BaseTest { + html?: string; + compileOptions?: Partial; + props?: Record; + error?: string; + runtime_error?: string; + warnings?: string[]; + test?: (args: { + assert: typeof import('vitest').assert; + target: any; + component: Record; + mod: any; + logs: any[]; + warnings: any[]; + renderer: typeof renderer; + serialize: typeof serialize; + dispatch_event: typeof dispatch_event; + }) => void | Promise; +} + +// eslint-disable-next-line no-console +const console_log = console.log; +// eslint-disable-next-line no-console +const console_warn = console.warn; + +const renderer_path = path.resolve(import.meta.dirname, 'renderer.js'); + +export function custom_renderer_suite() { + return suite_with_variants( + ['custom-renderer'], + (_variant, _config) => { + return false; + }, + (config, cwd) => { + return common_setup(cwd, config); + }, + async (config, cwd, _variant, common) => { + await run_test(cwd, config, common); + } + ); +} + +async function common_setup(cwd: string, config: CustomRendererTest) { + const compile_options: CompileOptions = { + generate: 'client', + rootDir: cwd, + runes: true, + customRenderer: renderer_path, + ...config.compileOptions + }; + + await compile_directory(cwd, 'client', compile_options); + + return compile_options; +} + +async function run_test(cwd: string, config: CustomRendererTest, compile_options: CompileOptions) { + let unintended_error = false; + let logs: any[] = []; + let warnings: any[] = []; + + { + const str = config.test?.toString() ?? ''; + let n = 0; + let i = 0; + while (i < str.length) { + if (str[i] === '(') n++; + if (str[i] === ')' && --n === 0) break; + i++; + } + + if (str.slice(0, i).includes('logs')) { + // eslint-disable-next-line no-console + console.log = (...args) => { + logs.push(...args); + }; + } + + if (str.slice(0, i).includes('warnings') || config.warnings) { + // eslint-disable-next-line no-console + console.warn = (...args) => { + if (typeof args[0] === 'string' && args[0].startsWith('%c[svelte]')) { + let message = args[0]; + message = message.slice(message.indexOf('%c', 2) + 2); + const lines = message.split('\n'); + if (lines.at(-1)?.startsWith('https://svelte.dev/e/')) { + lines.pop(); + } + message = lines.join('\n'); + warnings.push(message); + } else { + warnings.push(...args); + } + }; + } + } + + try { + const mod = await import(`${cwd}/_output/client/main.svelte.js`); + const target = create_root(); + + let unmount: (() => void) | undefined; + + try { + unmount = renderer.render(mod.default, { + target, + props: config.props ?? {} + }); + } catch (err) { + if (config.error) { + assert.include((err as Error).message, config.error); + return; + } + throw err; + } + + if (config.error) { + unintended_error = true; + assert.fail('Expected a runtime error'); + } + + if (config.html) { + const html = serialize(target); + assert.equal(html, `${config.html}`); + } + + try { + if (config.test) { + await config.test({ + assert, + target, + component: config.props ?? {}, + mod, + logs, + warnings, + renderer: renderer, + serialize, + dispatch_event + }); + } + + if (config.runtime_error) { + unintended_error = true; + assert.fail('Expected a runtime error'); + } + } finally { + unmount?.(); + + if (config.warnings) { + assert.deepEqual(warnings, config.warnings); + } + + // After unmount the target should be empty (only comments remain, which serialize to '') + const remaining = serialize(target); + assert.equal( + remaining, + '', + 'Expected component to leave nothing behind after unmount' + ); + } + } catch (err) { + if (config.runtime_error) { + assert.include((err as Error).message, config.runtime_error); + } else if (config.error && !unintended_error) { + assert.include((err as Error).message, config.error); + } else { + throw err; + } + } finally { + await setImmediate(); + console.log = console_log; + console.warn = console_warn; + } +} + +export function ok(value: any): asserts value { + if (!value) { + throw new Error(`Expected truthy value, got ${value}`); + } +} diff --git a/packages/svelte/tests/custom-renderers/test.ts b/packages/svelte/tests/custom-renderers/test.ts new file mode 100644 index 0000000000..f29cb0e322 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/test.ts @@ -0,0 +1,8 @@ +// @vitest-environment node +import { custom_renderer_suite, ok } from './shared'; + +const { test, run } = custom_renderer_suite(); + +export { test, ok }; + +await run(__dirname); From ecffd81c22ab429c498ee42cb3db209990559945 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 10:34:43 +0200 Subject: [PATCH 15/88] fix: types --- .../internal/client/custom-renderer/index.js | 17 ++++++++--------- packages/svelte/types/index.d.ts | 10 +++++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 0cf751a73c..d511d9a98e 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -20,22 +20,21 @@ import { push_renderer } from './state.js'; * @property {(element: TElement, name: string)=>void} removeAttribute - Remove the attribute with the given name from the element * @property {(element: TElement, name: string)=>boolean} hasAttribute - Return true if the element has an attribute with the given name * @property {(node: TNode, text: string)=>void} setText - Set the text content of the node to the given value. This should work for both text nodes and elements (setting text content on an element should replace all of it's children with a single text node) - * @property {(element: TElement | TFragment)=>TNode} getFirstChild - Return the first child of the element, or null if it has no children. This should work for both elements and fragments - * @property {(element: TElement | TFragment)=>TNode} getLastChild - Return the last child of the element, or null if it has no children. This should work for both elements and fragments - * @property {(element: TNode)=>TNode} getNextSibling - Return the next sibling of the node, or null if it has no next sibling + * @property {(element: TElement | TFragment)=>TNode | null} getFirstChild - Return the first child of the element, or null if it has no children. This should work for both elements and fragments + * @property {(element: TElement | TFragment)=>TNode | null} getLastChild - Return the last child of the element, or null if it has no children. This should work for both elements and fragments + * @property {(element: TNode)=>TNode | null} getNextSibling - Return the next sibling of the node, or null if it has no next sibling * @property {(parent: TElement | TFragment, element: TNode, anchor: TNode | null)=>void} insert - Insert the element into the parent before the anchor (if the anchor is null, insert at the end). This should work for both elements and fragments as parents * @property {(node: TNode)=>void} remove - Remove the node from the tree - * @property {(element: TNode)=>TNode} getParent - Return the parent of the element, or null if it has no parent - * @property {(node: TNode, deep: boolean)=>TNode} cloneNode - Return a clone of the node. If deep is true, all of the node's children should also be cloned + * @property {(element: TNode)=>TNode | null} getParent - Return the parent of the element, or null if it has no parent * @property {(target: TNode, type: string, handler: any, options?: any)=>void} addEventListener - Add an event listener of the given type and handler to the target node, with optional options * @property {(target: TNode, type: string, handler: any, options?: any)=>void} removeEventListener - Remove an event listener of the given type and handler from the target node, with optional options */ /** - * @template [const TFragment=unknown] - * @template [const TElement=unknown] - * @template [const TTextNode=unknown] - * @template [const TComment=unknown] + * @template [TFragment=unknown] + * @template [TElement=unknown] + * @template [TTextNode=unknown] + * @template [TComment=unknown] * @param {Renderer} renderer * @returns {Renderer & { render: (Component: any, options: { target: TFragment | TElement | TTextNode | TComment, props?: any }) => () => void }} */ diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index a3a02eddd3..cc17fe9ffb 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2568,7 +2568,7 @@ declare module 'svelte/reactivity/window' { } declare module 'svelte/renderer' { - export function createRenderer(renderer: Renderer): Renderer & { + export function createRenderer(renderer: Renderer): Renderer & { render: (Component: any, options: { target: TFragment | TElement | TTextNode | TComment; props?: any; @@ -2622,15 +2622,15 @@ declare module 'svelte/renderer' { /** * - Return the first child of the element, or null if it has no children. This should work for both elements and fragments */ - getFirstChild: (element: TElement | TFragment) => TNode; + getFirstChild: (element: TElement | TFragment) => TNode | null; /** * - Return the last child of the element, or null if it has no children. This should work for both elements and fragments */ - getLastChild: (element: TElement | TFragment) => TNode; + getLastChild: (element: TElement | TFragment) => TNode | null; /** * - Return the next sibling of the node, or null if it has no next sibling */ - getNextSibling: (element: TNode) => TNode; + getNextSibling: (element: TNode) => TNode | null; /** * - Insert the element into the parent before the anchor (if the anchor is null, insert at the end). This should work for both elements and fragments as parents */ @@ -2642,7 +2642,7 @@ declare module 'svelte/renderer' { /** * - Return the parent of the element, or null if it has no parent */ - getParent: (element: TNode) => TNode; + getParent: (element: TNode) => TNode | null; /** * - Add an event listener of the given type and handler to the target node, with optional options */ From e86da84c799d3c6d37dcabc513612015dff55e4b Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 11:24:52 +0200 Subject: [PATCH 16/88] fix: handle compiled `nodeValue`/`textContent` --- .../client/visitors/RegularElement.js | 22 ++++++++++++++++--- .../client/visitors/shared/fragment.js | 7 +++++- .../_config.js | 5 +++++ .../main.svelte | 5 +++++ .../text-expression-standalone/_config.js | 5 +++++ .../text-expression-standalone/main.svelte | 5 +++++ 6 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression-standalone-element/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression-standalone/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression-standalone/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index ec74c3ed27..0746c5f867 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -346,9 +346,25 @@ 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 (context.state.options.customRenderer) { + // 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)) { // For , or + + + + + + From 68300522543157926bc9b1d3f665d8eb3bee200d Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 16:28:18 +0200 Subject: [PATCH 22/88] fix: style and class special case --- .../client/visitors/RegularElement.js | 5 ++++- .../src/internal/client/dom/elements/class.js | 14 ++++++++++---- .../src/internal/client/dom/elements/style.js | 17 ++++++++++++----- .../_config.js | 9 ++++++++- .../main.svelte | 8 ++++++-- 5 files changed, 40 insertions(+), 13 deletions(-) rename packages/svelte/tests/custom-renderers/samples/{value-checked-attributes => special-attributes}/_config.js (82%) rename packages/svelte/tests/custom-renderers/samples/{value-checked-attributes => special-attributes}/main.svelte (68%) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index f5f574f328..ae09ca7922 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -38,7 +38,10 @@ 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' && + !context.state.options.customRenderer; const name = is_html ? node.name.toLowerCase() : node.name; context.state.template.push_element(name, node.start, is_html); diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index 038ce33f3e..063e67a70b 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -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); } } } diff --git a/packages/svelte/src/internal/client/dom/elements/style.js b/packages/svelte/src/internal/client/dom/elements/style.js index 3e05eec30e..b5b68eca28 100644 --- a/packages/svelte/src/internal/client/dom/elements/style.js +++ b/packages/svelte/src/internal/client/dom/elements/style.js @@ -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); } } diff --git a/packages/svelte/tests/custom-renderers/samples/value-checked-attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js similarity index 82% rename from packages/svelte/tests/custom-renderers/samples/value-checked-attributes/_config.js rename to packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js index b656ac37df..270aa7975a 100644 --- a/packages/svelte/tests/custom-renderers/samples/value-checked-attributes/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js @@ -20,6 +20,8 @@ export default test({ // Input 1: value="hello" const input_value = inputs[0]; assert.equal(input_value.attributes['value'], 'hello'); + assert.equal(input_value.attributes['class'], 'hello'); + assert.equal(input_value.attributes['style'], 'color: blue'); // Input 2: type="checkbox" checked="" const input_checked = inputs[1]; @@ -40,6 +42,8 @@ export default test({ // Input 5: spread attributes const input_spread = inputs[4]; assert.equal(input_spread.attributes['value'], 'hello'); + assert.equal(input_spread.attributes['class'], 'hello'); + assert.equal(input_spread.attributes['style'], 'color: blue'); // Click the button to update all values dispatch_event(button, 'click'); @@ -48,7 +52,8 @@ export default test({ // After update: // Input 1: value="world" assert.equal(input_value.attributes['value'], 'world'); - + assert.equal(input_value.attributes['class'], 'world'); + assert.equal(input_value.attributes['style'], 'color: red'); // Input 2: checked should be removed assert.equal(input_checked.attributes['checked'], undefined); @@ -61,5 +66,7 @@ export default test({ // Input 5: spread attributes should update value to "world" assert.equal(input_spread.attributes['value'], 'world'); + assert.equal(input_spread.attributes['class'], 'world'); + assert.equal(input_spread.attributes['style'], 'color: red'); } }); diff --git a/packages/svelte/tests/custom-renderers/samples/value-checked-attributes/main.svelte b/packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte similarity index 68% rename from packages/svelte/tests/custom-renderers/samples/value-checked-attributes/main.svelte rename to packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte index 097bdc0a39..1139780d5b 100644 --- a/packages/svelte/tests/custom-renderers/samples/value-checked-attributes/main.svelte +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/main.svelte @@ -1,12 +1,14 @@ - + @@ -14,6 +16,8 @@ From e2cedcd93e520cce8d82176bbe0b42ad7defccc9 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Tue, 31 Mar 2026 13:52:37 +0200 Subject: [PATCH 46/88] fix: don't lowercase attributes with custom renderers --- .../client/transform-template/template.js | 2 +- .../client/visitors/RegularElement.js | 2 +- .../client/visitors/shared/element.js | 5 ++- .../src/internal/client/dom/operations.js | 6 +-- .../samples/attribute-casing/_config.js | 37 +++++++++++++++++++ .../samples/attribute-casing/main.svelte | 9 +++++ .../samples/special-attributes/_config.js | 16 ++++---- 7 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/attribute-casing/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js index f546ce4962..e181419a9e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js @@ -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) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 468eadca87..5a74f5a115 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -235,7 +235,7 @@ export function RegularElement(node, context) { continue; } - const name = get_attribute_name(node, attribute); + const name = get_attribute_name(node, attribute, !!context.state.analysis.custom_renderer); if ( !is_custom_element && diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index 58ed88855f..d2d14d7da1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -132,9 +132,10 @@ export function build_attribute_value(value, context, memoize = (value) => value /** * @param {AST.RegularElement | AST.SvelteElement} element * @param {AST.Attribute} attribute + * @param {boolean} [custom_renderer] */ -export function get_attribute_name(element, attribute) { - if (!element.metadata.svg && !element.metadata.mathml) { +export function get_attribute_name(element, attribute, custom_renderer) { + if (!custom_renderer && !element.metadata.svg && !element.metadata.mathml) { return normalize_attribute(attribute.name); } diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index b5acb544bf..4539d12af5 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -579,7 +579,7 @@ export function set_element_checked(element, checked) { */ export function set_element_default_value(element, value) { if (renderer) { - renderer.setAttribute(element, 'defaultvalue', value); + renderer.setAttribute(element, 'defaultValue', value); return; } // @ts-expect-error @@ -599,9 +599,9 @@ export function set_element_default_value(element, value) { export function set_element_default_checked(element, checked) { if (renderer) { if (checked) { - renderer.setAttribute(element, 'defaultchecked', ''); + renderer.setAttribute(element, 'defaultChecked', ''); } else { - renderer.removeAttribute(element, 'defaultchecked'); + renderer.removeAttribute(element, 'defaultChecked'); } return; } diff --git a/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js b/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js new file mode 100644 index 0000000000..a44fc4ae1f --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js @@ -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); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/attribute-casing/main.svelte b/packages/svelte/tests/custom-renderers/samples/attribute-casing/main.svelte new file mode 100644 index 0000000000..ef6e7f2907 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attribute-casing/main.svelte @@ -0,0 +1,9 @@ + + +
static camelCase
+
dynamic camelCase
+static tabIndex +

spread camelCase

diff --git a/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js index 34ff2fe38c..4b839e5ad2 100644 --- a/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js @@ -27,16 +27,16 @@ export default test({ assert.equal(input_checked.attributes['type'], 'checkbox'); assert.equal(input_checked.attributes['checked'], ''); - // Input 3: value="fixed" defaultvalue="default_val" + // Input 3: value="fixed" defaultValue="default_val" const input_default_value = inputs[2]; assert.equal(input_default_value.attributes['value'], 'fixed'); - assert.equal(input_default_value.attributes['defaultvalue'], 'default_val'); + assert.equal(input_default_value.attributes['defaultValue'], 'default_val'); - // Input 4: type="checkbox" checked="" defaultchecked="" + // Input 4: type="checkbox" checked="" defaultChecked="" const input_default_checked = inputs[3]; assert.equal(input_default_checked.attributes['type'], 'checkbox'); assert.equal(input_default_checked.attributes['checked'], ''); - assert.equal(input_default_checked.attributes['defaultchecked'], ''); + assert.equal(input_default_checked.attributes['defaultChecked'], ''); // Input 5: spread attributes const input_spread = inputs[4]; @@ -56,12 +56,12 @@ export default test({ // Input 2: checked should be removed assert.equal(input_checked.attributes['checked'], undefined); - // Input 3: defaultvalue="new_default", value still "fixed" + // Input 3: defaultValue="new_default", value still "fixed" assert.equal(input_default_value.attributes['value'], 'fixed'); - assert.equal(input_default_value.attributes['defaultvalue'], 'new_default'); + assert.equal(input_default_value.attributes['defaultValue'], 'new_default'); - // Input 4: defaultchecked should be removed - assert.equal(input_default_checked.attributes['defaultchecked'], undefined); + // Input 4: defaultChecked should be removed + assert.equal(input_default_checked.attributes['defaultChecked'], undefined); // Input 5: spread attributes should update value to "world" assert.equal(input_spread.attributes['value'], 'world'); From e0a5e4fa35ea19350960a0f1110d826bf80f47a1 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Tue, 31 Mar 2026 14:47:53 +0200 Subject: [PATCH 47/88] fix: pass all the args to custom renderer events --- .../svelte/src/internal/client/dom/elements/events.js | 6 ++++-- .../samples/event-handler-no-propagation/_config.js | 10 +++++----- .../samples/event-handler-no-propagation/main.svelte | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 58217a7b9d..e662101621 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -69,15 +69,17 @@ export function create_event(event_name, dom, handler, options = {}) { /** * @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?.call(this, event); + 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); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/_config.js b/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/_config.js index b902b602e6..085ca682d8 100644 --- a/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/_config.js @@ -11,14 +11,14 @@ export default test({ const listeners = button.listeners?.click; assert.ok(listeners, 'button should have click listeners'); - // Call the handler with a plain object that is NOT a DOM Event. - // If handle_event_propagation is called, it will fail because - // it tries to access DOM-specific properties like composedPath, ownerDocument, etc. + // Call the handler with multiple arguments. + // Custom renderers may pass multiple arguments to event handlers, + // so we need to make sure all arguments are forwarded. for (const { handler } of listeners) { - handler.call(button, { type: 'click' }); + handler.call(button, { type: 'click' }, 'extra', 42); } flushSync(); - assert.deepEqual(logs, [{ type: 'click' }]); + assert.deepEqual(logs, [{ type: 'click' }, 'extra', 42]); } }); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/main.svelte b/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/main.svelte index c5c7626fb2..67a6e6ce92 100644 --- a/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/main.svelte +++ b/packages/svelte/tests/custom-renderers/samples/event-handler-no-propagation/main.svelte @@ -1,6 +1,6 @@ From d9a5f167f24e4f725c6b71a871bbed78db4170e2 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Wed, 1 Apr 2026 12:00:56 +0200 Subject: [PATCH 48/88] fix: import --- packages/svelte/src/internal/client/dev/css.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dev/css.js b/packages/svelte/src/internal/client/dev/css.js index cc868e92fa..0875cf2ba2 100644 --- a/packages/svelte/src/internal/client/dev/css.js +++ b/packages/svelte/src/internal/client/dev/css.js @@ -1,4 +1,4 @@ -import { remove_node } from '../dom/operations'; +import { remove_node } from '../dom/operations.js'; /** @type {Map>} */ var all_styles = new Map(); From 65b23d19fbe54fbfc94557bca6bd40af883651a4 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Wed, 1 Apr 2026 12:09:22 +0200 Subject: [PATCH 49/88] fix: options in migrate --- packages/svelte/src/compiler/migrate/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index 0370155c12..10a0274b30 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -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 } }; From 02d23d1bf02d4be42315f98428dadf618ac05fe7 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Wed, 1 Apr 2026 13:35:40 +0200 Subject: [PATCH 50/88] fix: class toggle --- .../src/internal/client/dom/operations.js | 4 +-- .../samples/class-directive/_config.js | 27 +++++++++++++++++++ .../samples/class-directive/main.svelte | 6 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/class-directive/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/class-directive/main.svelte diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 4539d12af5..fe9fbb4eda 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -770,7 +770,7 @@ export function set_css_text(element, value) { */ export function class_list_toggle(element, name, force) { if (renderer) { - const classes = element.getAttribute('class')?.split(/\s+/) ?? []; + const classes = renderer.getAttribute(element, 'class')?.split(/\s+/) ?? []; const has_class = classes.includes(name); if (force === has_class) { return; @@ -783,7 +783,7 @@ export function class_list_toggle(element, name, force) { classes.splice(index, 1); } } - element.setAttribute('class', classes.join(' ')); + renderer.setAttribute(element, 'class', classes.join(' ')); return; } element.classList.toggle(name, force); diff --git a/packages/svelte/tests/custom-renderers/samples/class-directive/_config.js b/packages/svelte/tests/custom-renderers/samples/class-directive/_config.js new file mode 100644 index 0000000000..b34ad3dc62 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/class-directive/_config.js @@ -0,0 +1,27 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '
content
', + test({ assert, target, serialize, dispatch_event }) { + const button = target.children.find( + (/**@type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + // Click to add the "active" class + dispatch_event(button, 'click'); + flushSync(); + + assert.equal( + serialize(target), + '
content
' + ); + + // Click again to remove the "active" class + dispatch_event(button, 'click'); + flushSync(); + + assert.equal(serialize(target), '
content
'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/class-directive/main.svelte b/packages/svelte/tests/custom-renderers/samples/class-directive/main.svelte new file mode 100644 index 0000000000..6335828e9c --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/class-directive/main.svelte @@ -0,0 +1,6 @@ + + +
content
+ From 8c70351882093bb5c476316d72529a2e1aab2bb9 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Wed, 1 Apr 2026 13:36:28 +0200 Subject: [PATCH 51/88] chore: changeset --- .changeset/salty-steaks-wash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/salty-steaks-wash.md diff --git a/.changeset/salty-steaks-wash.md b/.changeset/salty-steaks-wash.md new file mode 100644 index 0000000000..393f463644 --- /dev/null +++ b/.changeset/salty-steaks-wash.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: custom renderer api From 5c0e54ecff460ef9764ab8decbd381db7d0de337 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:18:23 +0200 Subject: [PATCH 52/88] fix: skip microtask in custom renderer `create_event` --- packages/svelte/src/internal/client/dom/elements/events.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index e662101621..aaababd9a6 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -96,9 +96,8 @@ 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(() => { add_event_listener(dom, event_name, target_handler, options); From 9ccd0b4e08c9c21b6be925b931be1ff77414599b Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:22:08 +0200 Subject: [PATCH 53/88] fix: cleanup in `finally` --- .../internal/client/custom-renderer/index.js | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 728918b856..f3df528411 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -51,21 +51,24 @@ export function createRenderer(renderer) { */ render(Component, { target, props, context }) { var cleanup = push_renderer(renderer); - const unmount = effect_root(() => { - var anchor = renderer.createComment(''); - renderer.insert(/** @type {*} */ (target), anchor, null); - boundary(/** @type {*} */ (anchor), { pending: () => {} }, (anchor) => { - push({}); - var ctx = /** @type {ComponentContext} */ (component_context); - if (context) ctx.c = context; - branch(() => { - /** @type {Function} */ (Component)(anchor, props); + try { + const unmount = effect_root(() => { + var anchor = renderer.createComment(''); + renderer.insert(/** @type {*} */ (target), anchor, null); + boundary(/** @type {*} */ (anchor), { pending: () => {} }, (anchor) => { + push({}); + var ctx = /** @type {ComponentContext} */ (component_context); + if (context) ctx.c = context; + branch(() => { + /** @type {Function} */ (Component)(anchor, props); + }); + pop(); }); - pop(); }); - }); - cleanup(); - return unmount; + return unmount; + } finally { + cleanup(); + } } }; } From 020dc9e1bb8fd6812b8c6eb71b307e965dd9ece4 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:25:37 +0200 Subject: [PATCH 54/88] fix: disable `@html` in custom renderer --- .../svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js | 4 ++++ .../tests/custom-renderers/samples/html-tag/_config.js | 5 +++++ .../tests/custom-renderers/samples/html-tag/main.svelte | 1 + 3 files changed, 10 insertions(+) create mode 100644 packages/svelte/tests/custom-renderers/samples/html-tag/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/html-tag/main.svelte diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js index 7b0e501760..f5ba9cd3d3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js @@ -2,12 +2,16 @@ /** @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'; /** * @param {AST.HtmlTag} node * @param {Context} context */ export function HtmlTag(node, context) { + if (context.state.analysis.custom_renderer) { + e.incompatible_with_custom_renderer(node, '`@html`'); + } if (context.state.analysis.runes) { validate_opening_tag(node, context.state, '@'); } diff --git a/packages/svelte/tests/custom-renderers/samples/html-tag/_config.js b/packages/svelte/tests/custom-renderers/samples/html-tag/_config.js new file mode 100644 index 0000000000..e00a19bcfc --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/html-tag/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + compile_error: '`@html` is not compatible with `customRenderer`' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/html-tag/main.svelte b/packages/svelte/tests/custom-renderers/samples/html-tag/main.svelte new file mode 100644 index 0000000000..7360363916 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/html-tag/main.svelte @@ -0,0 +1 @@ +{@html ""} \ No newline at end of file From 37bf4452f59443a3c17a8d61157574380bc50023 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:30:25 +0200 Subject: [PATCH 55/88] fix: event listeners in spread --- .../client/dom/elements/attributes.js | 8 ++--- .../samples/event-handler-spread/_config.js | 29 +++++++++++++++++++ .../samples/event-handler-spread/main.svelte | 13 +++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler-spread/main.svelte diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index ab34fe98d2..e1c3d805dc 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -395,7 +395,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 = renderer == null && can_delegate_event(event_name); if (is_capture_event(event_name)) { event_name = event_name.slice(0, -7); @@ -419,10 +419,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); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js b/packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js new file mode 100644 index 0000000000..ebcd14cc16 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js @@ -0,0 +1,29 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

0

', + test({ assert, target, serialize, logs }) { + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + assert.ok(button); + + const listeners = button.listeners?.click; + assert.ok(listeners, 'button should have click listeners'); + + // Call the handler with multiple arguments. + // Custom renderers may pass multiple arguments to event handlers, + // so we need to make sure all arguments are forwarded through spreads too. + for (const { handler } of listeners) { + handler.call(button, { type: 'click' }, 'extra', 42); + } + flushSync(); + + const html = serialize(target); + assert.equal(html, '

1

'); + + // Verify all arguments were forwarded to the actual handler + assert.deepEqual(logs, [{ type: 'click' }, 'extra', 42]); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler-spread/main.svelte b/packages/svelte/tests/custom-renderers/samples/event-handler-spread/main.svelte new file mode 100644 index 0000000000..eed8edde6f --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler-spread/main.svelte @@ -0,0 +1,13 @@ + + + +

{count}

From 8f8ac035cf33a8515254e9033fc06e6a85dfc896 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:36:51 +0200 Subject: [PATCH 56/88] fix: special value handling --- .../client/visitors/RegularElement.js | 3 +- .../samples/select-option/_config.js | 29 +++++++++++++++++++ .../samples/select-option/main.svelte | 10 +++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/select-option/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/select-option/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 5a74f5a115..4e906d8817 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -212,7 +212,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'); + !context.state.analysis.custom_renderer && + (name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked')); if (has_spread) { build_attribute_effect( diff --git a/packages/svelte/tests/custom-renderers/samples/select-option/_config.js b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js new file mode 100644 index 0000000000..7e6e62edd7 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js @@ -0,0 +1,29 @@ +import { test } from '../../test'; + +export default test({ + html: '

b

', + test({ assert, target, serialize }) { + const select = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'select' + ); + assert.ok(select); + + // The select element should have a value attribute set via the normal attribute path + assert.equal(select.attributes['value'], 'b'); + + // Each option should have its value as a regular attribute + const options = select.children.filter( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'option' + ); + assert.equal(options.length, 3); + assert.equal(options[0].attributes['value'], 'a'); + assert.equal(options[1].attributes['value'], 'b'); + assert.equal(options[2].attributes['value'], 'c'); + + const html = serialize(target); + assert.equal( + html, + '

b

' + ); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte b/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte new file mode 100644 index 0000000000..9b6e7e9e33 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/select-option/main.svelte @@ -0,0 +1,10 @@ + + + +

{selected}

From 84ee6a0e55db6a34c36ecb14f57a9d0205e40f00 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:49:25 +0200 Subject: [PATCH 57/88] fix: don't emit `selectedcontent` in custom render mode --- .../phases/3-transform/client/visitors/RegularElement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 4e906d8817..661df5d180 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -476,7 +476,7 @@ export function RegularElement(node, context) { context.state.after_update.push(...element_state.after_update); } - if (name === 'selectedcontent') { + if (name === 'selectedcontent' && !context.state.analysis.custom_renderer) { context.state.init.push( b.stmt( b.call( From 184eaeface65e18dd56e9471c6d4291c4070889d Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 09:49:59 +0200 Subject: [PATCH 58/88] fix: allow for an offscreen_anchor per renderer --- .../svelte/src/internal/client/dom/blocks/each.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index bee21178ae..429e2f5442 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -168,8 +168,11 @@ 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} */ +var offscreen_anchors = new WeakMap(); +var dom_weak_key = {}; /** * Returns an anchor node suitable for offscreen rendering. @@ -179,15 +182,18 @@ var offscreen_anchor; * @returns {TemplateNode} */ function get_offscreen_anchor() { - if (offscreen_anchor !== undefined) return offscreen_anchor; + // not doing it inline to please typescript + var cached = offscreen_anchors.get(renderer ?? dom_weak_key); + if (cached) return cached; - offscreen_anchor = create_text(); + var offscreen_anchor = create_text(); if (renderer) { var fragment = create_fragment(); append_child(fragment, offscreen_anchor); } + offscreen_anchors.set(renderer ?? dom_weak_key, offscreen_anchor); return offscreen_anchor; } From 49511b4a83dd0f674c20bf69f131465d74f4cb4d Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 10:09:09 +0200 Subject: [PATCH 59/88] fix: `defaultValue`/`defaultChecked` in spread attributes --- .../client/dom/elements/attributes.js | 10 +++ .../samples/default-value-spread/_config.js | 79 +++++++++++++++++++ .../samples/default-value-spread/main.svelte | 16 ++++ 3 files changed, 105 insertions(+) create mode 100644 packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/default-value-spread/main.svelte diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index e1c3d805dc..ec45e8cf0c 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -472,6 +472,16 @@ function set_attributes( element.__value = null; } } + } else if (is_default && 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')) diff --git a/packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js b/packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js new file mode 100644 index 0000000000..e5fd87d389 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js @@ -0,0 +1,79 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target, dispatch_event }) { + const inputs = target.children.filter( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'input' + ); + const button = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' + ); + + assert.equal(inputs.length, 4); + assert.ok(button); + + // Input 1: direct defaultValue attribute + const input_direct_val = inputs[0]; + // Input 2: defaultValue via spread + const input_spread_val = inputs[1]; + // Input 3: direct defaultChecked attribute (with `checked` alongside for consistent codegen) + const input_direct_chk = inputs[2]; + // Input 4: defaultChecked via spread + const input_spread_chk = inputs[3]; + + // --- Initial state --- + + // Direct defaultValue should appear as a renderer attribute + assert.equal(input_direct_val.attributes['value'], 'fixed'); + assert.equal(input_direct_val.attributes['defaultValue'], 'default_val'); + + // Spread defaultValue should produce the SAME result as direct defaultValue. + // BUG: without fix, spread bypasses the renderer and writes element.defaultValue directly + // so the attribute won't exist on the object-based renderer node + assert.equal(input_spread_val.attributes['value'], 'fixed'); + assert.equal( + input_spread_val.attributes['defaultValue'], + 'default_val', + 'defaultValue via spread should go through renderer.setAttribute, not element.defaultValue' + ); + + // Direct defaultChecked should appear as a renderer attribute + assert.equal(input_direct_chk.attributes['type'], 'checkbox'); + assert.equal(input_direct_chk.attributes['checked'], ''); + assert.equal(input_direct_chk.attributes['defaultChecked'], ''); + + // Spread defaultChecked should also go through the renderer API. + // set_element_default_checked treats true as a boolean attribute (empty string). + assert.equal(input_spread_chk.attributes['type'], 'checkbox'); + assert.equal( + input_spread_chk.attributes['defaultChecked'], + '', + 'defaultChecked via spread should go through renderer.setAttribute, not element.defaultChecked' + ); + + // --- After update --- + dispatch_event(button, 'click'); + flushSync(); + + // Direct defaultValue should update + assert.equal(input_direct_val.attributes['defaultValue'], 'new_default'); + + // Spread defaultValue should also update via renderer + assert.equal( + input_spread_val.attributes['defaultValue'], + 'new_default', + 'updated defaultValue via spread should go through renderer.setAttribute' + ); + + // Direct defaultChecked should be removed (false) + assert.equal(input_direct_chk.attributes['defaultChecked'], undefined); + + // Spread defaultChecked should also be removed via renderer + assert.equal( + input_spread_chk.attributes['defaultChecked'], + undefined, + 'updated defaultChecked=false via spread should go through renderer.removeAttribute' + ); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/default-value-spread/main.svelte b/packages/svelte/tests/custom-renderers/samples/default-value-spread/main.svelte new file mode 100644 index 0000000000..c24601749e --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/default-value-spread/main.svelte @@ -0,0 +1,16 @@ + + + + + + + + From 62d1ce78220484d4d7d16e07e896b7085e1f7d32 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 10:13:06 +0200 Subject: [PATCH 60/88] fix: default to empty props if props is undefined in render --- packages/svelte/src/internal/client/custom-renderer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index f3df528411..50d21cc68a 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -60,7 +60,7 @@ export function createRenderer(renderer) { var ctx = /** @type {ComponentContext} */ (component_context); if (context) ctx.c = context; branch(() => { - /** @type {Function} */ (Component)(anchor, props); + /** @type {Function} */ (Component)(anchor, props ?? {}); }); pop(); }); From 8a8b779586a2e9694324a54f67dbe0cfa61d8fde Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 10:21:57 +0200 Subject: [PATCH 61/88] fix: remove anchor from target on `unmount` --- .../svelte/src/internal/client/custom-renderer/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 50d21cc68a..ad6a83aa5d 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -4,6 +4,7 @@ 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] @@ -64,6 +65,11 @@ export function createRenderer(renderer) { }); pop(); }); + + return () => { + var parent = get_parent_node(/** @type {*} */ (anchor)); + if (parent) remove_child(parent, /** @type {*} */ (anchor)); + }; }); return unmount; } finally { From 9c1d17e51ffc637f846e83382a9e22227a783784 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 10:31:09 +0200 Subject: [PATCH 62/88] fix: handle autofocus in `set_attributes` --- .../internal/client/dom/elements/attributes.js | 11 ++++++++++- .../svelte-element-autofocus/_config.js | 18 ++++++++++++++++++ .../svelte-element-autofocus/main.svelte | 5 +++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index ec45e8cf0c..8bdd7d9311 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -431,7 +431,16 @@ function set_attributes( // avoid using the setter set_attribute(element, key, value); } else if (key === 'autofocus') { - autofocus(/** @type {HTMLElement} */ (element), Boolean(value)); + if (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. diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js new file mode 100644 index 0000000000..8d4602f7fb --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js @@ -0,0 +1,18 @@ +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + // If we got here, the component mounted without crashing on document.body access. + // Verify autofocus is set as a regular attribute. + const input = target.children.find( + (/** @type {any} */ n) => n.type === 'element' && n.name === 'input' + ); + assert.ok(input, 'input element should exist'); + assert.equal( + input.attributes['autofocus'], + 'true', + 'autofocus should be set as a regular attribute' + ); + assert.equal(input.attributes['value'], 'test', 'value should be set as a regular attribute'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte new file mode 100644 index 0000000000..bea9f28141 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/main.svelte @@ -0,0 +1,5 @@ + + + From f68d6af1739028d2575bf3dac5342ba81d454b2c Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 2 Apr 2026 10:35:52 +0200 Subject: [PATCH 63/88] fix: handle template as a normal tag --- .../phases/3-transform/client/visitors/RegularElement.js | 2 +- .../tests/custom-renderers/samples/template/_config.js | 5 +++++ .../tests/custom-renderers/samples/template/main.svelte | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/svelte/tests/custom-renderers/samples/template/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/template/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 661df5d180..4dc6e26ffe 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -439,7 +439,7 @@ export function RegularElement(node, context) { // The same applies if it's a `