From a4f0ce17cecb3be4f6d7b31b43def4e22df60984 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 8 Jun 2026 08:53:40 +0200 Subject: [PATCH] feat: accept `foreign` object in custom renderer to determine how to interleave custom renderers --- .../internal/client/custom-renderer/index.js | 7 +- .../client/custom-renderer/types.d.ts | 75 ++++++++++++++- .../internal/client/dom/blocks/branches.js | 29 +++++- .../src/internal/client/dom/blocks/each.js | 26 +++-- .../src/internal/client/dom/operations.js | 95 +++++++++++++++++-- 5 files changed, 208 insertions(+), 24 deletions(-) diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index 9d6fe9e840..2069215d68 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -13,8 +13,9 @@ import { get_parent_node, remove_child } from '../dom/operations.js'; * @template {object} [TElement=T extends DefaultNodes ? object : T['element']] * @template {object} [TTextNode=T extends DefaultNodes ? object : T['text']] * @template {object} [TComment=T extends DefaultNodes ? object : T['comment']] - * @param {Renderer} renderer - * @returns {Renderer & { render: , Exports extends Record>(component: ComponentType> | Component, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map }) => { component: Exports, unmount: () => void } }} + * @template {RendererNodes | undefined} [TForeignNodes=T extends DefaultNodes ? RendererNodes : T['foreign']] + * @param {Renderer} renderer + * @returns {Renderer & { render: , Exports extends Record>(component: ComponentType> | Component, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map }) => { component: Exports, unmount: () => void } }} */ export function createRenderer(renderer) { const compound_renderer = { @@ -26,7 +27,7 @@ export function createRenderer(renderer) { * @param {{} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map }} options */ render(Component, { target, props, context }) { - var pop_renderer = push_renderer(compound_renderer); + var pop_renderer = push_renderer(compound_renderer, compound_renderer); try { /** @type {Exports} */ diff --git a/packages/svelte/src/internal/client/custom-renderer/types.d.ts b/packages/svelte/src/internal/client/custom-renderer/types.d.ts index 2d0887619f..42b2b23785 100644 --- a/packages/svelte/src/internal/client/custom-renderer/types.d.ts +++ b/packages/svelte/src/internal/client/custom-renderer/types.d.ts @@ -3,6 +3,13 @@ export type Renderer< TElement extends object = object, TTextNode extends object = object, TComment extends object = object, + TForeignNodes extends RendererNodes | undefined = RendererNodes< + any, + any, + any, + any, + any + >, TNode extends TFragment | TElement | TTextNode | TComment = | TFragment | TElement @@ -83,26 +90,88 @@ export type Renderer< /** Remove an event listener of the given type and handler from the target node. */ removeEventListener(target: TElement, type: string, handler: any, options?: any): void; + + /** Operations used when this renderer is interleaved with DOM or another custom renderer. */ + foreign?: { + /** + * Insert a node from this renderer into a different renderer's parent before the anchor. + * If anchor is null, insert at the end. + */ + insertIntoForeign( + parent: TForeignNodes extends undefined + ? never + : + | DefinedRendererNodes['element'] + | DefinedRendererNodes['fragment'], + element: TNode, + anchor: + | DefinedRendererNodes['element'] + | DefinedRendererNodes['text'] + | DefinedRendererNodes['comment'] + | null + ): void; + /** + * Insert a node from a different renderer into this renderer's parent before the anchor. + * If anchor is null, insert at the end. + */ + insertForeign( + parent: TElement | TFragment, + element: + | DefinedRendererNodes['element'] + | DefinedRendererNodes['fragment'] + | DefinedRendererNodes['text'] + | DefinedRendererNodes['comment'], + anchor: + | DefinedRendererNodes['element'] + | DefinedRendererNodes['text'] + | DefinedRendererNodes['comment'] + | null + ): void; + + /** Remove a node that was inserted across renderer boundaries. */ + removeForeign( + node: + | DefinedRendererNodes['element'] + | DefinedRendererNodes['fragment'] + | DefinedRendererNodes['text'] + | DefinedRendererNodes['comment'] + ): void; + /** Remove a node that was inserted across renderer boundaries. */ + removeFromForeign(node: TNode): void; + }; }; +type DefinedRendererNodes | undefined> = + TNodes extends RendererNodes + ? TNodes + : RendererNodes; + export type RendererNodes< Fragment extends object, Element extends object, TextNode extends object, - Comment extends object + Comment extends object, + ForeignNode extends RendererNodes = RendererNodes< + any, + any, + any, + any, + any + > > = { fragment: Fragment; element: Element; text: TextNode; comment: Comment; + foreign?: ForeignNode; }; -export type NodeType = keyof RendererNodes; +export type NodeType = Exclude, 'foreign'>; // to detect if the user is passing a type or not we create this type utils that adds a unique symbol // that the user will never be able to pass in. We then create a a DefaultNodes type that is used as the default // type for the T generic of `createRenderer`. This means we can "detect" if the user is passing a type manually by // checking if the type extends DefaultNodes and using different default values -// for the other arguments (TFragment, TElement, TTextNode, TComment) +// for the other arguments (TFragment, TElement, TTextNode, TComment, TForeignNodes) export type UnsetObject = object & { readonly __unset: unique symbol }; export type DefaultNodes = RendererNodes; diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 3f3f54fe8b..6c43f52e72 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -20,7 +20,13 @@ import { get_last_child } from '../operations.js'; import { DEV } from 'esm-env'; -import { push_renderer, current_renderer, parent_renderer } from '../../custom-renderer/state.js'; +import { + push_renderer, + current_renderer, + parent_renderer, + set_parent_renderer, + set_renderer +} from '../../custom-renderer/state.js'; /** * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch @@ -96,6 +102,23 @@ export class BranchManager { this.#parent_renderer = parent_renderer; } + /** + * @param {() => void} fn + */ + #create_branch(fn) { + // we push current renderer twice because branches will always + // append to the current renderer + var pop_renderer = push_renderer(this.#renderer, this.#renderer); + try { + return branch(fn); + } finally { + pop_renderer?.(); + // we restore the parent_renderer so that an append after a + // branch will append to the correct renderer + set_parent_renderer(this.#parent_renderer); + } + } + /** * @param {Batch} batch */ @@ -225,13 +248,13 @@ export class BranchManager { append_child(fragment, target); this.#offscreen.set(key, { - effect: branch(() => fn(target)), + effect: this.#create_branch(() => fn(target)), fragment }); } else { this.#onscreen.set( key, - branch(() => fn(this.anchor)) + this.#create_branch(() => fn(this.anchor)) ); } } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 763bc06d22..7aea6b95a8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -50,7 +50,12 @@ import { current_batch } from '../../reactivity/batch.js'; import * as e from '../../errors.js'; import { tag } from '../../dev/tracing.js'; -import { push_renderer, current_renderer, parent_renderer } from '../../custom-renderer/state.js'; +import { + push_renderer, + current_renderer, + parent_renderer, + set_parent_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 @@ -216,12 +221,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; - // Capture the renderer that was active when this each block was created. - // Needed so that the commit callback can push the correct renderer when doing - // DOM operations outside of an effect context (e.g. as a batch commit callback). - var renderer = current_renderer; - var parent = parent_renderer; - if (is_controlled) { var parent_node = /** @type {Element} */ (node); @@ -230,6 +229,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f : /** @type {Text} */ (append_child(parent_node, create_text())); } + // Branch contents always render within the same renderer as the template that created + // the block. The outer parent renderer is restored after the branch has run. + var renderer = current_renderer; + var parent = parent_renderer; + if (hydrating) { hydrate_next(); } @@ -303,6 +307,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f state.pending.delete(batch); } + // we push current renderer twice because branches will always + // append to the current renderer + var pop_renderer = push_renderer(renderer, renderer); + var effect = block(() => { array = /** @type {V[]} */ (get(each_array)); var length = array.length; @@ -436,6 +444,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f // will now be `CLEAN`. get(each_array); }); + pop_renderer?.(); + // we restore the parent_renderer so that an append after a + // branch will append to the correct renderer + set_parent_renderer(parent); /** @type {EachState} */ var state = { effect, flags, items, pending, outrogroups: null, fallback }; diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 84bffe2bf9..079d275b7f 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -1,4 +1,5 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Renderer } from '../custom-renderer/types.js' */ import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; @@ -17,7 +18,8 @@ import { } from '#client/constants'; import { eager_block_effects } from '../reactivity/batch.js'; import { NAMESPACE_HTML } from '../../../constants.js'; -import { current_renderer } from '../custom-renderer/state.js'; +import { current_renderer, parent_renderer } from '../custom-renderer/state.js'; +import * as e from '../errors.js'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -407,12 +409,50 @@ export function append_child(parent, child) { * @param {Node} new_node */ export function insert_before(ref_node, new_node) { - if (current_renderer) { - var parent = current_renderer.getParent(ref_node); - current_renderer.insert(parent, new_node, ref_node); + var renderer = current_renderer; + var parent = parent_renderer; + + if (renderer === null && parent === null) { + // DOM into DOM + ref_node.before(new_node); return; } - ref_node.before(new_node); + + if (renderer === parent) { + // Same custom renderer into itself + // The DOM-to-DOM case returned above, so equal renderers here must both be non-null. + var same_parent = /** @type {NonNullable} */ (renderer).getParent(ref_node); + /** @type {NonNullable} */ (renderer).insert( + /** @type {any} */ (same_parent), + new_node, + ref_node + ); + return; + } + + if (parent === null) { + // Custom renderer into DOM + var dom_parent = ref_node.parentNode; + // The DOM-to-DOM case returned above, so a null parent here means renderer is non-null. + get_foreign(/** @type {NonNullable} */ (renderer)).insertIntoForeign( + dom_parent, + new_node, + ref_node + ); + return; + } + + if (renderer === null) { + // DOM into custom renderer + var custom_parent = parent.getParent(ref_node); + get_foreign(parent).insertForeign(custom_parent, new_node, ref_node); + return; + } + + // Custom renderer into a different custom renderer + var foreign_parent = parent.getParent(ref_node); + get_foreign(parent).insertForeign(foreign_parent, new_node, ref_node); + return; } /** @@ -434,11 +474,50 @@ export function insert_after(ref_node, new_node) { * @param {ChildNode} node */ export function remove_node(node) { - if (current_renderer) { - current_renderer.remove(node); + var renderer = current_renderer; + var parent = parent_renderer; + + if (renderer === null && parent === null) { + // DOM from DOM + node.remove(); return; } - node.remove(); + + if (renderer === parent) { + // Same custom renderer from itself + // The DOM-from-DOM case returned above, so equal renderers here must both be non-null. + /** @type {NonNullable} */ (renderer).remove(node); + return; + } + + if (parent === null) { + // Custom renderer from DOM + // The DOM-from-DOM case returned above, so a null parent here means renderer is non-null. + get_foreign(/** @type {NonNullable} */ (renderer)).removeFromForeign(node); + return; + } + + if (renderer === null) { + // DOM from custom renderer + get_foreign(parent).removeForeign(node); + return; + } + + // Custom renderer from a different custom renderer + get_foreign(parent).removeForeign(node); +} + +/** + * @param {Renderer} renderer + */ +function get_foreign(renderer) { + var foreign = renderer.foreign; + + if (foreign == null) { + e.renderer_missing_foreign(); + } + + return foreign; } /**