Skip to content

feat: custom renderers API#18042

Open
paoloricciuti wants to merge 126 commits into
mainfrom
svelte-custom-renderer
Open

feat: custom renderers API#18042
paoloricciuti wants to merge 126 commits into
mainfrom
svelte-custom-renderer

Conversation

@paoloricciuti
Copy link
Copy Markdown
Member

@paoloricciuti paoloricciuti commented Apr 1, 2026

Closes #15470

We're so back!

Finally, thanks to Syntax and SuppCo that sponsored the Custom Renderers initiative, I was able to work full-time for a bit on this (thanks again to my employer, Mainmatter for allowing me to do this).

This, the fact that we recently revisited the direction we were going (that lead to much less code needed), plus the fact that Claude now is decently good at navigating the Svelte codebase allowed me to do a lot of progress (I was also able to re-use part of the code I already had before).

As you can see, this is a big PR, but I tried to split the commits reasonably so that the review process shouldn't be too bad and there wasn't really a way to verify it worked before having built most of it. There are still a few To-dos but luckily, I also have a few more days to work on this (and at this point is in a place where I can also do it in my spare time).

To-dos

  • Documentation
  • Lift HTML-specific compiler warnings/errors if a custom renderer is defined
  • Write more tests
  • (Stretch) figure out if there's a way to use part of the huge test suite for the custom renderers too
  • (Stretch) figure out if there's a way to lint the DOM access in the runtime code

How it works

experimental.customRenderer is a new compile configuration option. It can be a string or a function that accepts the filename and returns a string. The value should be an NPM package or a module that exports the renderer as its default export. When this option is defined, the compilation output changes a bit (no delegated events, no inlining of node.nodeValue="", no customizable select, etc...basically we're doing a lot of optimization that are specific to the DOM which are skipped).

The compilation also changes because now the compiled component imports the module you specify, uses from_tree instead of from_html, push the renderer at the beginning of the component and pop it at the end...basically this

<p>hello</p>

gets compiled to

import * as $ from 'svelte/internal/client';
import $renderer from 'your-custom-renderer';

var root = $.from_tree([['p', null, 'hello']]);

export default function Main($$anchor) {
	var $$pop_renderer = $.push_renderer($renderer);
	var p = root();

	$.append($$anchor, p);
	$$pop_renderer();
}

What does a custom renderer looks like? You can have a look at the one I created for testing in packages/svelte/tests/custom-renderers/renderer.js to have a more practical example but basically, you can import createRenderer from svelte/renderer and then specify a series of DOM-like operations in your "world".

import { createRenderer } from 'svelte/renderer';

const renderer = createRenderer({
	createFragment() {},
	createElement(name) {},
	createTextNode(data) {},
	createComment(data) {},
	nodeType(node) {},
	getNodeValue(node) {},
	getAttribute(el, name) {},
	setAttribute(el, key, value) {},
	removeAttribute(el, name) {},
	hasAttribute(el, name) {},
	setText(node, text) {},
	getFirstChild(el) {},
	getLastChild(el) {},
	getNextSibling(node) {},
	insert(parent, node, anchor) {},
	remove(node) {},
	getParent(node) {},
	addEventListener(target, type, handler, options) {},
	removeEventListener(target, type, handler, options) {}
});

You can then use the return value to "mount" your component

const root = renderer.createFragment();

const unmount = renderer.render(MyComponent, {
	target: root,
	props: {
		/* ... */
	},
	context: new Map()
});

A good custom renderer is crucial to make svelte works properly so we will need to document this correctly (even though I don't expect people to create custom renderers in their day to day and the Svelte team will likely be the primary user of this API).

A few examples of this:

  • insert assumes that the insertion works like the DOM: if you insert something that already has a parent it should be removed from where it is.
  • insert-ing a fragment means inserting all the children of the fragment in the parent.
  • If your system doesn't have the concept of a parent/child you will need to keep track of the relationship yourself
  • a comment or a fragment can literally just be objects you tuck information to (in case your system doesn't have those concepts

Limits

A few features of Svelte are designed specifically for the DOM and thus are disabled if you try to compile a component with a custom renderer:

  • bind: on regular elements is forbidden, since svelte register known DOM events to keep the variables in sync.
  • transition:, animate:, in: and out: are forbidden, since, once again, those use DOM manipulation under the hood.
  • svelte:window, svelte:body, svelte:document, svelte:head ... I mean, do I really need to explain why this is forbidden?
  • css: injected is also forbidden since it appends the style tag to the document
  • createRawSnippet throws a runtime error since it relies on the template tag to generate the HTML elements from the string you return
  • You can't hydrate a custom renderer compiled component (because in most cases it doesn't make sense since there's no SSR)

Another quirk is that you can technically interleave components compiled with different renderers (imagine a DOM component into a Threlte one) but:

  • it requires a bit of manual handling (the custom renderer need to have a before function on the comment that will receive the fragment/element/text that the component is trying to "mount")
  • Currently is not possible to @render a snippet compiled with a different renderer from the one that is invoking @render. This means if I'm mounting a DOM component into a Threlte component I can't pass a snippet (unless that snippet is exported from a component compiled without a renderer and imported into the Threlte component)

These limitations might change before we merge if we find a way to make them work.

What this PR does?

The main job of this PR is to centralize every DOM operation in operations.js as an exported function. This allows the function to check if a renderer is available so that it can call the method on the renderer instead of the DOM method. The renderer is also captured in every effect created in the component, since it needs to be "pushed" again before the effect execute. The same is true for boundaries, batches and each blocks.

I've also added a somewhat decent test suite that uses a render-to-object renderer that renders the svelte components to...well...an object. This allows the tests to be "similar" to the rest of the test suite (there's even an object-to-HTML string helper to assert the shape of the component) but is executed in a node environment, so every access to DOM API will actually throw.

I've changed some of the validation in place, but there's still a few warnings and errors that don't make sense, which I plan to fix before merging.

A few questions

  • Right now, there are some places that are guaranteed to never be touched by the custom renderers paths (either because it's behind a hydration flag or because it's part of a feature that is forbidden at compile time). I didn't touch the DOM access in those part of the codebase. The advantage of this is that the diff is smaller and is much more clear what the intent is. The disadvantage is that now we don't have a clear rule of "never access the DOM in the runtime folder".
  • Right now, Typescript is a big fat lie in the whole codebase: we always assume what we are dealing with is a Node/HTMLElement, but in reality it could be anything by the moment we drop the custom renderers API. Changing the types to be object could help us with the maintainability of the custom renderers API (now Typescript will yell at us if we try to access element.value without checking)...but it could make the maintainability of DOM Svelte a nightmare (because now you have to check everything and everywhere). Should we keep it a lie?
  • We technically could produce shorter compiled code with custom renderers (there are a few methods that literally do nothing and bail immediately if there's a custom renderer). However, that would mean a more messy (and thus less maintainable) compiler code... I would say having the same output weights more than a few bytes of compiler output.

Extra

To test this out, I (admittedly Claude) built an opentui custom renderer to render svelte component to the terminal...here's a small preview.

Screen.Recording.2026-03-31.at.15.17.13.mp4

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
svelte-previews Error Error May 13, 2026 8:33am

Request Review

@vexkiddy
Copy link
Copy Markdown

so does this mean that we can port our web apps to mobile with a custom renderer ? or does someone need to make a custom renderer to do that ? :D

@paoloricciuti
Copy link
Copy Markdown
Member Author

so does this mean that we can port our web apps to mobile with a custom renderer ? or does someone need to make a custom renderer to do that ? :D

You still need a target that supports native. Lynx is definitely a good option, especially with CSS support. That said, mobile has generally some strict rules about the shape of the code. For example, in Lynx (like in React Native) you can't have text without a wrapping text element. You can write a custom renderer to map from web elements to lynx elements, but that will probably not work out of the box.

That said, once we have the Lynx or Svelte Native renderer, you will be likely able to write a mobile app that also renders to Web since they support it.

@ramiroaisen
Copy link
Copy Markdown

transition: in: and out: should work with the renderers, we can just create javascript based transitions instead of css ones

@paoloricciuti
Copy link
Copy Markdown
Member Author

transition: in: and out: should work with the renderers, we can just create javascript based transitions instead of css ones

Even if you limit them to JS based ones...how do you transition something in the terminal? How do you transition something in Lynx? How you do it if the renderer just renders a JS object?

The concept of a transition itself is just something that applies to mostly DOM. There might be some way to allow the creators of the renderers to transition in and out but honestly I can't think of an API for that...any ideas?

@ramiroaisen
Copy link
Copy Markdown

transition: in: and out: should work with the renderers, we can just create javascript based transitions instead of css ones

Even if you limit them to JS based ones...how do you transition something in the terminal? How do you transition something in Lynx? How you do it if the renderer just renders a JS object?

The concept of a transition itself is just something that applies to mostly DOM. There might be some way to allow the creators of the renderers to transition in and out but honestly I can't think of an API for that...any ideas?

No, it is not DOM specific, js transitions have a tick function, what the user does with that is user's choice, you could use Element PAPI to change a property of an element in every tick

@paoloricciuti
Copy link
Copy Markdown
Member Author

No, it is not DOM specific, js transitions have a tick function, what the user does with that is user's choice, you could use Element PAPI to change a property of an element in every tick

I guess we could enable them AND throw a descriptive error in case you try to build a css one 🤔

A small issue would be that you would not be able to use the backed in transitions but I assume we could just document that.

@paoloricciuti
Copy link
Copy Markdown
Member Author

Ok I took a look: imho is not worth doing this in the first draft.

Transitions, even with the tick function, are still coordinated by element.animate which is obviously a DOM thing. Even if we could branch in the presence of a custom renderer and avoid using animate for the delay/playing status the loop to invoke tick still assumes requestAnimationFrame is a thing which, once again, we can't guarantee in all environments. Maybe there's a way to do this as agnostically as possible but including this in the first draft of the feature would be a massive burden (and we would need to deal with every bug that will emerge from this extremely environment-dependent feature).

@Rich-Harris
Copy link
Copy Markdown
Member

strong agree: transitions in their current form are absolutely not something we want to bring with us

@CristianRos
Copy link
Copy Markdown

Not sure if this is doable but wanted to bounce some thoughts after reading the conversation. Are lifecycle hooks like onMount/onDestroy exposed to renderer authors at all? Would it be feasible to add an optional async hook like beforeUnmount/beforeDestroy on the renderer API itself? Something that returns a Promise the runtime would await before actually removing the node/fragment.

The idea is that renderer authors could use it to handle whatever they need like animations but without the renderer API having to commit to transition: in: and out:. Since it's optional, the DOM renderer just doesn't implement it at all.

@Rich-Harris
Copy link
Copy Markdown
Member

onMount and onDestroy will work with custom renderers, no changes required. They're just thin wrappers around $effect.

Adding new lifecycle hooks to prevent the destruction of an effect is exactly what we'd planned, yes — the long term goal is to provide a way to do transition-like things in attachments, precisely so that component authors have more programmatic control (initially for the sake of building more complex layout transitions/physics-based transitions/staggered transitions/etc, but it also extends the capability to custom renderers). No timeframe on that though, as our energy has been focused on async stuff lately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom renderers support

8 participants