Skip to content

feat: custom renderers API#18042

Open
paoloricciuti wants to merge 89 commits intomainfrom
svelte-custom-renderer
Open

feat: custom renderers API#18042
paoloricciuti wants to merge 89 commits intomainfrom
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

@paoloricciuti
Copy link
Copy Markdown
Member Author

paoloricciuti commented Apr 2, 2026

@mustafa0x fixed the one I thought it was worth fixing and were not false positives (btw thanks for the review)...commented on the rest just for the review process in case someone disagree with me:

18, 19: I don’t think that’s an issue…an error on those paths would still break the app I think

20: I don’t think it’s an issue wither…it would actually be wrong the opposite imho, converting everything to string works for the DOM not necessarily for custom renderers

23: not a huge issue imho…it will still fail to import the renderer so if you have an empty string you have much bigger problems

25: not an issue: the return type is specifically ‘’ to avoid going through any specific DOM path

40: I don’t think it’s relevant

8: foreignObject is meant to not be handled differently since it’s a custom renderer

13: I wonder if this should be fixed… I can see how someone might want to create a render that mimic the DOM one, but other than “for fun” what’s the reason?

24: let’s be honest…if you fuck up your import it’s up to you to correct it lol

27: not sure is worth the complexity…nobody is gonna generate server AND custom renderer

25: that’s fine, it’s dev only and that’s why it needs to be an object of some sort

36: left a to-do, but probably not worth fixing now

37: not really an issue imho…migrate is basically dead code now

38: null should be still treated as false

Priority 2 and 3 are all out of scope imho

@NullVoxPopuli
Copy link
Copy Markdown

does attachShadow need to be in the interface? for <template shadowrootmode="open"> support?

@paoloricciuti
Copy link
Copy Markdown
Member Author

paoloricciuti commented Apr 2, 2026

IMHO template shouldn't have a special treatment in custom renderer world: it's up to whoever writes the renderer to treat setAttribute of shadowrootmode with a value of open on an element that is a template to invoke the right methods if it makes sense in "their world"

@NullVoxPopuli
Copy link
Copy Markdown

it's just a bit goofy, because you don't actually emit a <template> and you have to attach to the parent element 🤔 so setAttribute on a <template> shouldn't actually do anything with attributes or an element, I think. it's like parent.attachShadow(...) -- i suppose it's "just" a bit of coordination with all these things

@paoloricciuti
Copy link
Copy Markdown
Member Author

The problem is that template is only special in HTML...what if you want to write a lynx renderer where template means a string that renders markdown?

@paoloricciuti
Copy link
Copy Markdown
Member Author

Small update:

Now 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)

@paoloricciuti paoloricciuti force-pushed the svelte-custom-renderer branch from 38d45c1 to 4440cec Compare April 3, 2026 10:53
@mustafa0x
Copy link
Copy Markdown
Contributor

mustafa0x commented Apr 5, 2026

AI finally wrapped up it's review. It claims ~30 P0s remain. That number seems high, and most of the issues seem a bit obscure.

Note: I incorporated your fixes and your feedback into the review. I hope the review is of some use, and not only noise.

@paoloricciuti
Copy link
Copy Markdown
Member Author

Is absolutely of use and not at all noice, especially done in a separate file like this...will take a look later/tomorrow/Tuesday (depending on when I will have some time).

Thanks 🤟🏻

@paoloricciuti
Copy link
Copy Markdown
Member Author

Checked them...a lot were wrong assumptions but overall solid ideas

  • 140, 141, 142 all refers to 139 and assume it’s not fixed…fixiing that fixed all of those
  • 151 i think there’s a PR open for this but I still don’t understand the use case
  • 152, 153 also refer to this “problem”
  • 128 while technically a “bug” it’s not actually a real issue…interlieving renderers require a bit of managing on the custom renderer side which is what this bug needs
  • 131: this is an interesting point…I guess if you want to interleave DOM and custom renderer you will need to start with a DOM one regardless and I guess that would fix the issue
  • 132 same as above
  • 146 once again interleaving do requires special handling at the custom renderer level so I don’t think we can do something there
  • 20: i don’t think custom renderers should have an events property
  • 21 maybe it should have a transformError but not sure if it’s needed, let’s skip it for the moment
  • 55 same thing here…I don’t think it make sense for a custom renderer to have an anchor
  • 56: i think it’s fine if they diverge for the moment
  • 98: if the render method throws you have bigger problems than a leaked anchor
  • 119, 120, 121: i think intro and outro in mount only applies to the component being mounted which in the case of render will always be a custom renderer compiled one so no need to include them
  • 82, 100, 101, 102, 147, 148, 149 + a lot more: that’s intentional…every special case for the DOM should be skipped in custom renderers…it’s up to the custom renderer creator to restore those if it wants
  • 52, 53: that’s intentional…while the API looks DOM it should work anyway with custom renderers
  • 144 depends on 54 that I fixed
  • The rest are the old ones that were purposely skipped

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

4 participants