Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
a03b3f3
feat: custom renderers api
paoloricciuti Mar 17, 2026
f35e12c
Merge remote-tracking branch 'origin/main' into svelte-custom-renderer
paoloricciuti Mar 18, 2026
203d22b
Merge remote-tracking branch 'origin/main' into svelte-custom-renderer
paoloricciuti Mar 20, 2026
23e52db
chore: better types
paoloricciuti Mar 27, 2026
2145510
Merge remote-tracking branch 'origin/main' into svelte-custom-renderer
paoloricciuti Mar 27, 2026
101d1ce
chore: better types and more complete interface
paoloricciuti Mar 28, 2026
ac40179
chore: remove getter
paoloricciuti Mar 28, 2026
0a0212c
chore: new operations functions to replace direct DOM access
paoloricciuti Mar 28, 2026
4eb2c0b
fix: import correctly
paoloricciuti Mar 28, 2026
6d13e5a
fix: store current renderer in effect and push it before executing it
paoloricciuti Mar 28, 2026
a59efbf
fix: wrap component in boundary in `render`
paoloricciuti Mar 30, 2026
432c023
fix: push and pop renderer when needed
paoloricciuti Mar 30, 2026
9ae3faa
chore: centralize methods in `operations.js`
paoloricciuti Mar 30, 2026
c459824
fix: don't clone node if a renderer is available
paoloricciuti Mar 30, 2026
cf874c0
fix: generate types
paoloricciuti Mar 30, 2026
bd70ab6
fix: events dom access
paoloricciuti Mar 30, 2026
49cff54
chore: initial test suite
paoloricciuti Mar 30, 2026
ecffd81
fix: types
paoloricciuti Mar 30, 2026
e86da84
fix: handle compiled `nodeValue`/`textContent`
paoloricciuti Mar 30, 2026
f828ea3
chore: better test renderer
paoloricciuti Mar 30, 2026
0f44733
chore: boundary test
paoloricciuti Mar 30, 2026
beddf45
chore: render into a fragment in tests
paoloricciuti Mar 30, 2026
5456125
Merge remote-tracking branch 'origin/main' into svelte-custom-renderer
paoloricciuti Mar 30, 2026
7dca985
chore: add each reactive and key tests
paoloricciuti Mar 30, 2026
37bd61d
fix: attributes dom access
paoloricciuti Mar 30, 2026
6830052
fix: style and class special case
paoloricciuti Mar 30, 2026
a0d0f01
fix: un-special case customizable-select
paoloricciuti Mar 30, 2026
2dc8fd1
chore: add full object in output
paoloricciuti Mar 30, 2026
ff5e835
fix: handle autofocus and muted
paoloricciuti Mar 30, 2026
f91c2fd
fix: use operations functions everywhere
paoloricciuti Mar 30, 2026
b4cf3d0
chore: move option under experimental
paoloricciuti Mar 31, 2026
c189bdc
chore: make option accept a function
paoloricciuti Mar 31, 2026
100fbbf
fix: conditional invocation
paoloricciuti Mar 31, 2026
098cdeb
fix: disallow css injected with custom renderer
paoloricciuti Mar 31, 2026
df42e4b
fix: add validations for `svelte:dom` elements
paoloricciuti Mar 31, 2026
9167c8d
fix: disallow `bind` and `transitions`
paoloricciuti Mar 31, 2026
2242004
chore: cleanup
paoloricciuti Mar 31, 2026
38dc4ae
chore: cleanup
paoloricciuti Mar 31, 2026
1886dc9
chore: cleanup
paoloricciuti Mar 31, 2026
c322fdf
fix: `validate_snippet_arguments`
paoloricciuti Mar 31, 2026
13ae09b
fix: throw descriptive error if using `createRawSnippet` using custom…
paoloricciuti Mar 31, 2026
8270a2f
fix: make sure returned elements are not primitives
paoloricciuti Mar 31, 2026
a0b60cb
fix: handle context withing `render`
paoloricciuti Mar 31, 2026
61ea8e9
chore: cleanup
paoloricciuti Mar 31, 2026
206bdf4
fix: allow usage of `$props.id`
paoloricciuti Mar 31, 2026
1270d5d
chore: cleanup
paoloricciuti Mar 31, 2026
c573720
chore: better types
paoloricciuti Mar 31, 2026
700454d
fix: lint
paoloricciuti Mar 31, 2026
a0650d0
fix: events don't go through propagation
paoloricciuti Mar 31, 2026
e2cedcd
fix: don't lowercase attributes with custom renderers
paoloricciuti Mar 31, 2026
e0a5e4f
fix: pass all the args to custom renderer events
paoloricciuti Mar 31, 2026
d9a5f16
fix: import
paoloricciuti Apr 1, 2026
65b23d1
fix: options in migrate
paoloricciuti Apr 1, 2026
63d8744
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 1, 2026
02d23d1
fix: class toggle
paoloricciuti Apr 1, 2026
8c70351
chore: changeset
paoloricciuti Apr 1, 2026
cdc57e0
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 1, 2026
81bf921
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 2, 2026
5c0e54e
fix: skip microtask in custom renderer `create_event`
paoloricciuti Apr 2, 2026
9ccd0b4
fix: cleanup in `finally`
paoloricciuti Apr 2, 2026
020dc9e
fix: disable `@html` in custom renderer
paoloricciuti Apr 2, 2026
37bf445
fix: event listeners in spread
paoloricciuti Apr 2, 2026
8f8ac03
fix: special value handling
paoloricciuti Apr 2, 2026
84ee6a0
fix: don't emit `selectedcontent` in custom render mode
paoloricciuti Apr 2, 2026
184eaef
fix: allow for an offscreen_anchor per renderer
paoloricciuti Apr 2, 2026
49511b4
fix: `defaultValue`/`defaultChecked` in spread attributes
paoloricciuti Apr 2, 2026
62d1ce7
fix: default to empty props if props is undefined in render
paoloricciuti Apr 2, 2026
8a8b779
fix: remove anchor from target on `unmount`
paoloricciuti Apr 2, 2026
9c1d17e
fix: handle autofocus in `set_attributes`
paoloricciuti Apr 2, 2026
f68d6af
fix: handle template as a normal tag
paoloricciuti Apr 2, 2026
28e70f8
fix: allow one "window" for each renderer
paoloricciuti Apr 2, 2026
ce3b8ee
fix: explicit `is_html` false if render is not null
paoloricciuti Apr 2, 2026
8a82750
chore: left TODO for style manipulation
paoloricciuti Apr 2, 2026
ac62333
fix: return component exports from `render`
paoloricciuti Apr 2, 2026
11c8787
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 2, 2026
da9efc5
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 2, 2026
7f9021a
chore: move init operations into separate module
paoloricciuti Apr 2, 2026
ce4442d
fix: allow DOM components to be mounted into custom renderers (requir…
paoloricciuti Apr 2, 2026
7d37884
fix: throw error if snippet is rendered in renderer different from co…
paoloricciuti Apr 2, 2026
0031d8f
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 2, 2026
883611f
chore: remove duplicate error
paoloricciuti Apr 2, 2026
801c8ef
fix: don't emit HTML warnings in custom renderer compile
paoloricciuti Apr 3, 2026
4440cec
chore: better types
paoloricciuti Apr 3, 2026
0b3d179
chore: better types
paoloricciuti Apr 3, 2026
b391869
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 5, 2026
d7e01b7
Merge branch 'main' into svelte-custom-renderer
paoloricciuti Apr 7, 2026
9272440
fix: push renderer at the very top of component
paoloricciuti Apr 7, 2026
7c74482
fix: push same returned renderer in `renderer.render`
paoloricciuti Apr 7, 2026
b4431e9
fix: disallow on directive with custom renderers
paoloricciuti Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/salty-steaks-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: custom renderer api
12 changes: 12 additions & 0 deletions documentation/docs/98-reference/.generated/client-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ Failed to hydrate the application
Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`
```

### invalid_snippet_in_custom_renderer

```
`createRawSnippet` cannot be used with a custom renderer
```

### lifecycle_legacy_only

```
Expand Down Expand Up @@ -229,6 +235,12 @@ The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files

This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.

### snippet_renderer_mismatch

```
A snippet created in a component with a custom renderer cannot be rendered by a different renderer
```

### state_descriptors_fixed

```
Expand Down
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,12 @@ Cannot use `await` in deriveds and template expressions, or at the top level of
Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case
```

### incompatible_with_custom_renderer

```
%message% is not compatible with `customRenderer`
```

### inspect_trace_generator

```
Expand Down
8 changes: 8 additions & 0 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ This can happen if you render a hydratable on the client that was not rendered o

> Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`

## invalid_snippet_in_custom_renderer

> `createRawSnippet` cannot be used with a custom renderer

## lifecycle_legacy_only

> `%name%(...)` cannot be used in runes mode
Expand All @@ -173,6 +177,10 @@ This can happen if you render a hydratable on the client that was not rendered o

This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.

## snippet_renderer_mismatch

> A snippet created in a component with a custom renderer cannot be rendered by a different renderer

## state_descriptors_fixed

> Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ The same applies to components:

> `<%name%>` does not support non-event attributes or spread attributes
## incompatible_with_custom_renderer

> %message% is not compatible with `customRenderer`
## js_parse_error

> %message%
Expand Down
7 changes: 7 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"./internal/disclose-version": {
"default": "./src/internal/disclose-version.js"
},
"./internal/init-operations": {
"default": "./src/internal/init-operations.js"
},
"./internal/flags/async": {
"default": "./src/internal/flags/async.js"
},
Expand Down Expand Up @@ -91,6 +94,10 @@
"types": "./types/index.d.ts",
"default": "./src/reactivity/window/index.js"
},
"./renderer": {
"types": "./types/index.d.ts",
"default": "./src/renderer/index.js"
},
"./server": {
"types": "./types/index.d.ts",
"default": "./src/server/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/renderer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './types/index.js';
12 changes: 11 additions & 1 deletion packages/svelte/scripts/generate-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ const pkg = JSON.parse(fs.readFileSync(`${dir}/package.json`, 'utf-8'));

// For people not using moduleResolution: 'bundler', we need to generate these files. Think about removing this in Svelte 6 or 7
// It may look weird, but the imports MUST be ending with index.js to be properly resolved in all TS modes
for (const name of ['action', 'animate', 'easing', 'motion', 'store', 'transition', 'legacy']) {
for (const name of [
'action',
'animate',
'easing',
'motion',
'store',
'transition',
'legacy',
'renderer'
]) {
fs.writeFileSync(`${dir}/${name}.d.ts`, "import './types/index.js';\n");
}

Expand Down Expand Up @@ -44,6 +53,7 @@ await createBundle({
[`${pkg.name}/motion`]: `${dir}/src/motion/public.d.ts`,
[`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`,
[`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`,
[`${pkg.name}/renderer`]: `${dir}/src/internal/client/custom-renderer/index.js`,
[`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`,
[`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`,
[`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`,
Expand Down
10 changes: 10 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,16 @@ export function illegal_element_attribute(node, name) {
e(node, 'illegal_element_attribute', `\`<${name}>\` does not support non-event attributes or spread attributes\nhttps://svelte.dev/e/illegal_element_attribute`);
}

/**
* %message% is not compatible with `customRenderer`
* @param {null | number | NodeLike} node
* @param {string} message
* @returns {never}
*/
export function incompatible_with_custom_renderer(node, message) {
e(node, 'incompatible_with_custom_renderer', `${message} is not compatible with \`customRenderer\`\nhttps://svelte.dev/e/incompatible_with_custom_renderer`);
}

/**
* %message%
* @param {null | number | NodeLike} node
Expand Down
12 changes: 10 additions & 2 deletions packages/svelte/src/compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,23 @@ export function compile(source, options) {

let parsed = _parse(source);

const { customElement: customElementOptions, ...parsed_options } = parsed.options || {};
const {
customElement: customElementOptions,
customRenderer: custom_renderer,
...parsed_options
} = parsed.options || {};

/** @type {ValidatedCompileOptions} */
const combined_options = {
...validated,
...parsed_options,
customElementOptions,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes,
experimental: {
...validated.experimental,
...(custom_renderer !== undefined ? { customRenderer: () => custom_renderer } : {})
}
};

if (parsed.metadata.ts) {
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/migrate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
};

Expand Down
12 changes: 12 additions & 0 deletions packages/svelte/src/compiler/phases/1-parse/read/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -193,6 +197,14 @@ export default function read_options(node) {
}
}

if (component_options.css === 'injected' && component_options.customRenderer !== undefined) {
// Find the css attribute node for the error position
const css_attribute = node.attributes.find(
(/** @type {any} */ a) => a.type === 'Attribute' && a.name === 'css'
);
e.incompatible_with_custom_renderer(css_attribute ?? node, "`css: 'injected'`");
}

return component_options;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,12 @@ export function analyze_component(root, source, options) {

const custom_element_from_option = options.customElement({ filename: options.filename });
const css = options.css({ filename: options.filename });
const custom_renderer = options.experimental.customRenderer?.({ filename: options.filename });

if (css === 'injected' && custom_renderer !== undefined) {
e.incompatible_with_custom_renderer(null, "`css: 'injected'`");
}

const custom_element = options.customElementOptions ?? custom_element_from_option;
const is_custom_element = !!options.customElementOptions || custom_element_from_option;

Expand Down Expand Up @@ -529,6 +535,7 @@ export function analyze_component(root, source, options) {
event_directive_node: null,
uses_event_attributes: false,
custom_element,
custom_renderer,
inject_styles: css === 'injected' || is_custom_element,
accessors:
is_custom_element ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import * as e from '../../../errors.js';
* @param {Context} context
*/
export function AnimateDirective(node, context) {
if (context.state.analysis.custom_renderer) {
e.incompatible_with_custom_renderer(node, '`animate:`');
}

context.next({ ...context.state, expression: node.metadata.expression });

if (node.metadata.expression.has_await) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.analysis.custom_renderer) {
node.metadata.delegated =
parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export function BindDirective(node, context) {
parent?.type === 'SvelteDocument' ||
parent?.type === 'SvelteBody'
) {
if (context.state.analysis.custom_renderer) {
e.incompatible_with_custom_renderer(node, '`bind:`');
}
if (node.name in binding_properties) {
const property = binding_properties[node.name];
if (property.valid_elements && !property.valid_elements.includes(parent.name)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
export function ExpressionTag(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';

if (in_template && context.state.parent_element) {
if (in_template && context.state.parent_element && !context.state.analysis.custom_renderer) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, '@');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { mark_subtree_dynamic } from './shared/fragment.js';

Expand All @@ -8,6 +9,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
* @param {Context} context
*/
export function OnDirective(node, context) {
if (context.state.analysis.custom_renderer) {
e.incompatible_with_custom_renderer(node, '`on:`');
}

if (context.state.analysis.runes) {
const parent_type = context.path.at(-1)?.type;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { runes } from '../../../state.js';
*/
export function RegularElement(node, context) {
validate_element(node, context);
check_element(node, context);

if (!context.state.analysis.custom_renderer) {
check_element(node, context);
}

node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);
Expand All @@ -34,7 +37,9 @@ export function RegularElement(node, context) {
if (node.name === 'textarea' && node.fragment.nodes.length > 0) {
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute' && attribute.name === 'value') {
e.textarea_invalid_content(node);
if (!context.state.analysis.custom_renderer) {
e.textarea_invalid_content(node);
}
}
}

Expand Down Expand Up @@ -110,7 +115,10 @@ export function RegularElement(node, context) {

// Special case: <select>, <option> or <optgroup> with rich content needs special hydration handling
// We mark the subtree as dynamic so parent elements properly include the child init code
if (is_customizable_select_element(node) || node.name === 'selectedcontent') {
if (
(is_customizable_select_element(node) || node.name === 'selectedcontent') &&
!context.state.analysis.custom_renderer
) {
// Mark the element's own fragment as dynamic so it's not treated as static
node.fragment.metadata.dynamic = true;
// Also mark ancestor fragments so parents properly include the child init code
Expand Down Expand Up @@ -157,7 +165,7 @@ export function RegularElement(node, context) {
mark_subtree_dynamic(context.path);
}

if (context.state.parent_element) {
if (context.state.parent_element && !context.state.analysis.custom_renderer) {
let past_parent = false;
let only_warn = false;
const ancestors = [context.state.parent_element];
Expand Down Expand Up @@ -218,7 +226,8 @@ export function RegularElement(node, context) {
context.state.analysis.source[node.end - 2] === '/' &&
!is_void(node_name) &&
!is_svg(node_name) &&
!is_mathml(node_name)
!is_mathml(node_name) &&
!context.state.analysis.custom_renderer
) {
w.element_invalid_self_closing_tag(node, node.name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { disallow_children } from './shared/special-element.js';
* @param {Context} context
*/
export function SvelteBody(node, context) {
if (context.state.analysis.custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:body>`');
}

disallow_children(node);
for (const attribute of node.attributes) {
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { is_event_attribute } from '../../../utils/ast.js';
* @param {Context} context
*/
export function SvelteDocument(node, context) {
if (context.state.analysis.custom_renderer) {
e.incompatible_with_custom_renderer(node, '`<svelte:document>`');
}

disallow_children(node);

for (const attribute of node.attributes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { mark_subtree_dynamic } from './shared/fragment.js';
*/
export function SvelteElement(node, context) {
validate_element(node, context);
check_element(node, context);

if (!context.state.analysis.custom_renderer) {
check_element(node, context);
}

node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);
Expand Down
Loading
Loading