diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index ca536ec5c6..9fc9526a69 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -225,6 +225,12 @@ Cannot do `bind:%key%={undefined}` when `%key%` has a fallback value Rest element properties of `$props()` such as `%property%` are readonly ``` +### renderer_missing_foreign + +``` +A custom renderer must define `foreign` to interleave with DOM or another custom renderer +``` + ### rune_outside_svelte ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index d97fea27b4..7778630174 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -171,6 +171,10 @@ This can happen if you render a hydratable on the client that was not rendered o > Rest element properties of `$props()` such as `%property%` are readonly +## renderer_missing_foreign + +> A custom renderer must define `foreign` to interleave with DOM or another custom renderer + ## rune_outside_svelte > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files 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 8bfc38f127..92360f1a3b 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 @@ -588,13 +588,23 @@ export function client_component(analysis, options) { if (custom_renderer !== undefined) { // when the custom renderer feature is enabled every component pushes a renderer: components // with a renderer module push `$renderer`, DOM components push `null` + const pop_renderer = b.stmt(b.call('$$pop_renderer')); + let pop_renderer_index = component_block.body.length; + + if (should_inject_context) { + pop_renderer_index -= 1; + if (needs_store_cleanup) pop_renderer_index -= 1; + if (needs_store_cleanup && component_returned_object.length > 0) pop_renderer_index -= 1; + } + component_block.body.unshift( b.var( '$$pop_renderer', b.call('$.push_renderer', custom_renderer ? b.id('$renderer') : b.literal(null)) ) ); - component_block.body.push(b.stmt(b.call('$$pop_renderer'))); + + component_block.body.splice(pop_renderer_index + 1, 0, pop_renderer); } if (state.events.size > 0) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js index 3283b03379..81a9c3fbb3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js @@ -45,10 +45,14 @@ export function RenderTag(node, context) { ); if (node.metadata.dynamic) { - // In custom renderer components, validate that the snippet is compatible - // with the current renderer before rendering it - if (custom_renderer) { - snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function); + // In custom-renderer-aware builds, validate that the snippet is compatible + // with the current renderer before rendering it. + if (custom_renderer !== undefined) { + snippet_function = b.call( + '$.validate_snippet_renderer', + custom_renderer ? b.id('$renderer') : b.literal(null), + snippet_function + ); } // If we have a chain expression then ensure a nullish snippet function gets turned into an empty one @@ -64,10 +68,14 @@ export function RenderTag(node, context) { ) ); } else { - // In custom renderer components, validate that the snippet is compatible - // with the current renderer before rendering it - if (custom_renderer) { - snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function); + // In custom-renderer-aware builds, validate that the snippet is compatible + // with the current renderer before rendering it. + if (custom_renderer !== undefined) { + snippet_function = b.call( + '$.validate_snippet_renderer', + custom_renderer ? b.id('$renderer') : b.literal(null), + snippet_function + ); } statements.push( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js index dc82192561..effa8ab89f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SnippetBlock.js @@ -79,10 +79,13 @@ export function SnippetBlock(node, context) { ? b.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body)) : b.arrow(args, body); - // wrap snippets in components with a custom renderer so they can only be - // rendered by the same renderer that compiled them - if (custom_renderer) { - snippet = b.call('$.renderer_snippet', b.id('$renderer'), snippet); + // In custom-renderer-aware builds, preserve the renderer that compiled the snippet. + if (custom_renderer !== undefined) { + snippet = b.call( + '$.renderer_snippet', + custom_renderer ? b.id('$renderer') : b.literal(null), + snippet + ); } const declaration = b.const(node.expression, snippet); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index c9c90a8e9e..758102d693 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -395,8 +395,12 @@ export function build_component(node, component_name, loc, context) { ? b.call('$.wrap_snippet', b.id(context.state.analysis.name), slot_fn) : slot_fn; - if (custom_renderer) { - children_fn = b.call('$.renderer_snippet', b.id('$renderer'), children_fn); + if (custom_renderer !== undefined) { + children_fn = b.call( + '$.renderer_snippet', + custom_renderer ? b.id('$renderer') : b.literal(null), + children_fn + ); } // create `children` prop... diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index c7fce61ff9..11c7bcf615 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -12,6 +12,7 @@ import { extract_identifiers } from '../../utils/ast.js'; import check_graph_for_cycles from '../2-analyze/utils/check_graph_for_cycles.js'; import is_reference from 'is-reference'; import { set_scope } from '../scope.js'; +import { custom_renderer } from '../../state.js'; /** * Match Svelte 4 behaviour by sorting ConstTag nodes in topological order @@ -290,7 +291,8 @@ export function clean_nodes( !first.metadata.dynamic && !first.attributes.some( (attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--') - ))), + ))) && + custom_renderer == undefined, /** if a component/snippet/each block starts with text, we need to add an anchor comment so that its text node doesn't get fused with its surroundings */ is_text_first: (parent.type === 'Fragment' || diff --git a/packages/svelte/src/internal/client/custom-renderer/index.js b/packages/svelte/src/internal/client/custom-renderer/index.js index c7ff363a67..34174e6377 100644 --- a/packages/svelte/src/internal/client/custom-renderer/index.js +++ b/packages/svelte/src/internal/client/custom-renderer/index.js @@ -6,7 +6,8 @@ * @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']] - * @template {Renderer} [R=Renderer] + * @template {RendererNodes | undefined} [TForeignNodes=T extends DefaultNodes ? RendererNodes : T['foreign']] + * @template {Renderer} [R=Renderer] * @param {R} renderer * @returns {R} */ diff --git a/packages/svelte/src/internal/client/custom-renderer/state.js b/packages/svelte/src/internal/client/custom-renderer/state.js index 007da9ee8d..7e24ab0550 100644 --- a/packages/svelte/src/internal/client/custom-renderer/state.js +++ b/packages/svelte/src/internal/client/custom-renderer/state.js @@ -7,6 +7,14 @@ import { hydrating, set_hydrating } from '../dom/hydration.js'; */ export let current_renderer = null; +/** + * The renderer that was active before `current_renderer` was pushed. This + * allows custom renderers to be interleaved (e.g. a custom renderer rendering + * into another one) by keeping a reference to the renderer one level up. + * @type {Renderer | null} + */ +export let parent_renderer = null; + /** * @param {Renderer | null} value */ @@ -14,11 +22,21 @@ export function set_renderer(value) { current_renderer = value; } +/** + * @param {Renderer | null} value + */ +export function set_parent_renderer(value) { + parent_renderer = value; +} + /** * * @param {Renderer | null} value + * @param {Renderer | null} [parent] the renderer to restore as the + * `parent_renderer`. Defaults to the current renderer so that, in the common case, the + * renderer that was active before this push becomes the parent. */ -export function push_renderer(value) { +export function push_renderer(value, parent = current_renderer) { var previous_hydrating = hydrating; var should_disable_hydration = hydrating && value != null; // this is to allow hydration code to treeshake @@ -26,10 +44,13 @@ export function push_renderer(value) { set_hydrating(false); } var previous_renderer = current_renderer; + var previous_parent_renderer = parent_renderer; + parent_renderer = parent; current_renderer = value; return () => { current_renderer = previous_renderer; + parent_renderer = previous_parent_renderer; if (should_disable_hydration) { set_hydrating(previous_hydrating); } 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/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 3cca70076a..dffc8ddad2 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -230,7 +230,7 @@ export class Boundary { this.#pending_effect = branch(() => pending(this.#anchor)); queue_micro_task(() => { - var pop_renderer = push_renderer(this.#effect.r); + var pop_renderer = push_renderer(this.#effect.r, this.#effect.pr); var fragment = (this.#offscreen_fragment = create_fragment()); var anchor = create_text(); @@ -324,7 +324,7 @@ export class Boundary { set_active_reaction(this.#effect); set_component_context(this.#effect.ctx); - var pop_renderer = push_renderer(this.#effect.r); + var pop_renderer = push_renderer(this.#effect.r, this.#effect.pr); try { Batch.ensure(); @@ -368,7 +368,7 @@ export class Boundary { } if (this.#offscreen_fragment) { - var pop_renderer = push_renderer(this.#effect.r); + var pop_renderer = push_renderer(this.#effect.r, this.#effect.pr); 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 2c045df256..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 } 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 @@ -79,6 +85,12 @@ export class BranchManager { */ #renderer = null; + /** + * The parent renderer that was active when this BranchManager was created. + * @type {Renderer | null} + */ + #parent_renderer = null; + /** * @param {TemplateNode} anchor * @param {boolean} transition @@ -87,6 +99,24 @@ export class BranchManager { this.anchor = anchor; this.#transition = transition; this.#renderer = current_renderer; + 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); + } } /** @@ -96,7 +126,7 @@ export class BranchManager { // if this batch was made obsolete, bail if (!this.#batches.has(batch)) return; - var pop_renderer = push_renderer(this.#renderer); + var pop_renderer = push_renderer(this.#renderer, this.#parent_renderer); var key = /** @type {Key} */ (this.#batches.get(batch)); @@ -218,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 5e8bccd37d..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 } 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,11 +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; - if (is_controlled) { var parent_node = /** @type {Element} */ (node); @@ -229,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(); } @@ -267,7 +272,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return; } - var pop_renderer = push_renderer(renderer); + var pop_renderer = push_renderer(renderer, parent); state.pending.delete(batch); @@ -302,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; @@ -435,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/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index b9607cea33..401d7cfc9c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -15,8 +15,9 @@ import * as e from '../../errors.js'; import { DEV } from 'esm-env'; import { get_first_child, get_next_sibling, insert_before, node_type } from '../operations.js'; import { prevent_snippet_stringification } from '../../../shared/validate.js'; +import { has_own_property } from '../../../shared/utils.js'; import { BranchManager } from './branches.js'; -import { current_renderer } from '../../custom-renderer/state.js'; +import { current_renderer, push_renderer } from '../../custom-renderer/state.js'; /** * @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn @@ -35,7 +36,26 @@ export function snippet(node, get_snippet, ...args) { e.invalid_snippet(); } - branches.ensure(snippet, snippet && ((anchor) => snippet(anchor, ...args))); + branches.ensure( + snippet, + snippet && + ((anchor) => { + var renderer = /** @type {any} */ (snippet).__renderer; + var has_renderer = has_own_property.call(/** @type {any} */ (snippet), '__renderer'); + + if (has_renderer) { + var pop_renderer = push_renderer(renderer, renderer); + + try { + return snippet(anchor, ...args); + } finally { + pop_renderer(); + } + } + + return snippet(anchor, ...args); + }) + ); }, EFFECT_TRANSPARENT); } @@ -98,7 +118,16 @@ export function renderer_snippet(expected_renderer, fn) { * @returns {T} */ export function validate_snippet_renderer(expected_renderer, fn) { - if (fn != null && /** @type {any} */ (fn).__renderer !== expected_renderer) { + if (fn == null) return fn; + + var has_renderer = has_own_property.call(/** @type {any} */ (fn), '__renderer'); + + if ( + (expected_renderer === null && + has_renderer && + /** @type {any} */ (fn).__renderer !== expected_renderer) || + (expected_renderer !== null && /** @type {any} */ (fn).__renderer !== expected_renderer) + ) { e.snippet_renderer_mismatch(); } return fn; 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; } /** diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 9e68e7867f..fca13e208e 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -15,7 +15,7 @@ import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../constants.js'; -import { current_renderer } from '../custom-renderer/state.js'; +import { current_renderer, parent_renderer } from '../custom-renderer/state.js'; import { active_effect } from '../runtime.js'; import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { @@ -50,10 +50,36 @@ const SCRIPT_TAG = IS_XHTML ? 'script' : 'SCRIPT'; export function assign_nodes(start, end) { var effect = /** @type {Effect} */ (active_effect); if (effect.nodes === null) { - effect.nodes = { start, end, a: null, t: null }; + effect.nodes = { + start, + end, + segments: should_segment_nodes(effect) + ? [{ start, end, r: current_renderer, pr: parent_renderer }] + : null, + a: null, + t: null + }; + } else if (should_segment_nodes(effect)) { + var nodes = effect.nodes; + (nodes.segments ??= [{ start: nodes.start, end: nodes.end, r: effect.r, pr: effect.pr }]).push({ + start, + end, + r: current_renderer, + pr: parent_renderer + }); } } +/** + * @param {Effect} effect + */ +function should_segment_nodes(effect) { + return ( + (current_renderer !== effect.r || parent_renderer !== effect.pr) && + (current_renderer?.foreign != null || parent_renderer?.foreign != null) + ); +} + /** * @param {string} content * @param {number} flags @@ -386,6 +412,12 @@ export function append(anchor, dom) { // of the parent component. Check for defined for that reason to avoid rewinding the parent's end marker. if ((effect.f & REACTION_RAN) === 0 || effect.nodes.end === null) { effect.nodes.end = hydrate_node; + + // this is to cover interleaved custom renders where an hydrated dom component interleaves with a custom renderer + // since it will use segments instead of start/end, we need to make sure to update the segment's end as well + if (effect.nodes.segments !== null) { + effect.nodes.segments[0].end = hydrate_node; + } } hydrate_next(); diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 651480a3d6..66cdf40056 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -428,6 +428,22 @@ export function props_rest_readonly(property) { } } +/** + * A custom renderer must define `foreign` to interleave with DOM or another custom renderer + * @returns {never} + */ +export function renderer_missing_foreign() { + if (DEV) { + const error = new Error(`renderer_missing_foreign\nA custom renderer must define \`foreign\` to interleave with DOM or another custom renderer\nhttps://svelte.dev/e/renderer_missing_foreign`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/renderer_missing_foreign`); + } +} + /** * The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files * @param {string} rune diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 91afbee853..03afc6c644 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -9,7 +9,12 @@ import { set_dev_stack } from '../context.js'; import { Boundary } from '../dom/blocks/boundary.js'; -import { current_renderer, set_renderer } from '../custom-renderer/state.js'; +import { + current_renderer, + parent_renderer, + set_parent_renderer, + set_renderer +} from '../custom-renderer/state.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -134,6 +139,7 @@ export function capture() { var previous_component_context = component_context; var previous_batch = /** @type {Batch} */ (current_batch); var previous_renderer = current_renderer; + var previous_parent_renderer = parent_renderer; if (DEV) { var previous_dev_stack = dev_stack; @@ -145,6 +151,7 @@ export function capture() { set_component_context(previous_component_context); set_renderer(previous_renderer); + set_parent_renderer(previous_parent_renderer); if (activate_batch && (previous_effect.f & DESTROYED) === 0) { // TODO we only need optional chaining here because `{#await ...}` blocks @@ -277,6 +284,7 @@ export function unset_context(deactivate_batch = true) { set_active_reaction(null); set_component_context(null); set_renderer(null); + set_parent_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 ae7408abd8..ad2fb4ce27 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -45,7 +45,7 @@ import { Batch, collected_effects, current_batch } from './batch.js'; import { flatten } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; -import { push_renderer, current_renderer } from '../custom-renderer/state.js'; +import { push_renderer, current_renderer, parent_renderer } from '../custom-renderer/state.js'; /** * @param {'$effect' | '$effect.pre' | '$inspect'} rune @@ -114,7 +114,8 @@ function create_effect(type, fn) { teardown: null, wv: 0, ac: null, - r: current_renderer + r: current_renderer, + pr: parent_renderer }; if (DEV) { @@ -513,14 +514,14 @@ export function destroy_block_effect_children(signal) { export function destroy_effect(effect, remove_dom = true) { var removed = false; - var pop_renderer = push_renderer(effect.r); + var pop_renderer = push_renderer(effect.r, effect.pr); if ( (remove_dom || (effect.f & HEAD_EFFECT) !== 0) && effect.nodes !== null && effect.nodes.end !== null ) { - remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end)); + remove_effect_nodes(effect); removed = true; } @@ -564,6 +565,7 @@ export function destroy_effect(effect, remove_dom = true) { effect.ac = effect.b = effect.r = + effect.pr = null; pop_renderer?.(); @@ -584,6 +586,26 @@ export function remove_effect_dom(node, end) { } } +/** + * @param {Effect} effect + */ +function remove_effect_nodes(effect) { + var nodes = /** @type {NonNullable} */ (effect.nodes); + var segments = nodes.segments; + + if (segments === null) { + remove_effect_dom(nodes.start, /** @type {TemplateNode} */ (nodes.end)); + return; + } + + for (var i = segments.length - 1; i >= 0; i--) { + var segment = segments[i]; + var pop_renderer = push_renderer(segment.r, segment.pr); + remove_effect_dom(segment.start, /** @type {TemplateNode} */ (segment.end)); + pop_renderer?.(); + } +} + /** * Detach an effect from the effect tree, freeing up memory and * reducing the amount of work that happens on subsequent traversals @@ -738,7 +760,7 @@ export function aborted(effect = /** @type {Effect} */ (active_effect)) { export function move_effect(effect, fragment) { if (!effect.nodes) return; - var pop_renderer = push_renderer(effect.r); + var pop_renderer = push_renderer(effect.r, effect.pr); /** @type {TemplateNode | null} */ var node = effect.nodes.start; diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 5f513ef1ad..8b5be77af2 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -65,12 +65,21 @@ export interface Derived extends Value, Reaction { export interface EffectNodes { start: TemplateNode; end: TemplateNode | null; + /** Renderer-local ranges, used when a component interleaves custom and foreign-rendered nodes */ + segments: null | EffectNodeSegment[]; /** $.animation */ a: AnimationManager | null; /** $.transition */ t: TransitionManager[] | null; } +export interface EffectNodeSegment { + start: TemplateNode; + end: TemplateNode | null; + r: Renderer | null; + pr: Renderer | null; +} + export interface Effect extends Reaction { /** * Branch effects store their start/end nodes so that they can be @@ -97,6 +106,8 @@ export interface Effect extends Reaction { b: Boundary | null; /** The renderer this effect was created with */ r: Renderer | null; + /** The parent renderer this effect was created with */ + pr: 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/render.js b/packages/svelte/src/internal/client/render.js index bd22114445..3390fe78fd 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -168,7 +168,7 @@ const listeners = new Map(); */ function _mount(Component, options) { if (options.renderer) { - var pop_renderer = push_renderer(options.renderer); + var pop_renderer = push_renderer(options.renderer, options.renderer); try { return _mount_inner(Component, options); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 32222dfb52..16c6b79f09 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -450,7 +450,7 @@ export function update_effect(effect) { active_effect = effect; is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts - var pop_renderer = push_renderer(effect.r); + var pop_renderer = push_renderer(effect.r, effect.pr); if (DEV) { var previous_component_fn = dev_current_component_function; diff --git a/packages/svelte/tests/custom-renderers/renderer.ts b/packages/svelte/tests/custom-renderers/renderer.ts index 674ac510e6..977f2b97dd 100644 --- a/packages/svelte/tests/custom-renderers/renderer.ts +++ b/packages/svelte/tests/custom-renderers/renderer.ts @@ -8,11 +8,12 @@ import { createRenderer } from '../../src/renderer/index.js'; -type ObjElement = { +export type ObjElement = { type: 'element'; name: string; attributes: Record; children: ObjNode[]; + elements_children: Array; listeners: Record>; parent: ObjNode | null; }; @@ -21,10 +22,14 @@ type ObjComment = { type: 'comment'; value: string; parent: ObjNode | null; - before: (node: any) => void; }; -type ObjFragment = { type: 'fragment'; children: ObjNode[]; parent: ObjNode | null }; -type ObjNode = ObjElement | ObjText | ObjComment | ObjFragment; +export type ObjFragment = { + type: 'fragment'; + children: ObjNode[]; + parent: ObjNode | null; + elements_children: Array; +}; +export type ObjNode = ObjElement | ObjText | ObjComment | ObjFragment; function insert_node( parent: ObjNode & { children?: ObjNode[] }, @@ -32,6 +37,10 @@ function insert_node( anchor: ObjNode | null ) { if (node.type === 'fragment') { + if (parent.type === 'element' || parent.type === 'fragment') { + parent.elements_children = node.elements_children; + } + const children = [...(node.children ?? [])]; for (const child of children) { insert_node(parent, child, anchor); @@ -69,19 +78,31 @@ function remove_from_parent(node: ObjNode) { children.splice(idx, 1); } -export const dom_elements: Array = []; +const mounted_in_dom_elements = new Map(); + +const mounted = new Map< + HTMLElement | DocumentFragment | Text | Comment, + ObjElement | ObjFragment +>(); const renderer = createRenderer<{ fragment: ObjFragment; element: ObjElement; text: ObjText; comment: ObjComment; + foreign: { + comment: Comment; + element: HTMLElement; + text: Text; + fragment: DocumentFragment; + }; }>({ createFragment() { return { type: 'fragment', children: [], - parent: null + parent: null, + elements_children: [] }; }, @@ -92,7 +113,8 @@ const renderer = createRenderer<{ attributes: {}, children: [], listeners: {}, - parent: null + parent: null, + elements_children: [] }; }, @@ -108,12 +130,7 @@ const renderer = createRenderer<{ return { type: 'comment', value: data, - parent: null, - // adding this allows for this renderer to interleave with a DOM-based renderer - // the argument will be the DOM node that represent a DOM Component being mounted - before(node) { - dom_elements.push(node); - } + parent: null }; }, @@ -189,6 +206,35 @@ const renderer = createRenderer<{ target.listeners[type] = target.listeners[type].filter( (/** @type {any} */ l) => l.handler !== handler ); + }, + + foreign: { + insertForeign(parent, element, anchor) { + parent.elements_children.push(element); + mounted.set(element, parent); + }, + removeForeign(node) { + const parent = mounted.get(node); + if (!parent) return; + const idx = parent.elements_children.indexOf(node); + if (idx !== -1) parent.elements_children.splice(idx, 1); + mounted.delete(node); + }, + insertIntoForeign(parent, element, anchor) { + const custom_rendered = document.createElement('custom-rendered'); + custom_rendered.textContent = JSON.stringify(element, (key, value) => { + if (key === 'parent') return undefined; + return value; + }); + parent.insertBefore(custom_rendered, anchor); + mounted_in_dom_elements.set(element, custom_rendered); + }, + removeFromForeign(node) { + const custom_rendered_node = mounted_in_dom_elements.get(node); + if (!custom_rendered_node) return; + custom_rendered_node.parentNode?.removeChild(custom_rendered_node); + mounted_in_dom_elements.delete(node); + } } }); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/Child.svelte b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/Child.svelte new file mode 100644 index 0000000000..189c1a8a8c --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/Child.svelte @@ -0,0 +1,9 @@ + + + + +
+ {message} +
diff --git a/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/_config.js b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/_config.js new file mode 100644 index 0000000000..460b77d563 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/_config.js @@ -0,0 +1,16 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test-dom.test'; + +export default test({ + html: '', + async test({ assert, component, target }) { + const [div] = target.children[0].elements_children; + assert.instanceOf(div, HTMLDivElement); + assert.equal(div.outerHTML, '
hello from child
'); + + component.hide(); + flushSync(); + + assert.isFalse(target.children[0].elements_children.includes(div)); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/main.svelte b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/main.svelte new file mode 100644 index 0000000000..acb448db9d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component-removal/main.svelte @@ -0,0 +1,15 @@ + + + + {#if visible} + + {/if} + diff --git a/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component/_config.js b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component/_config.js index 21ff9d835e..6c3c0d60bd 100644 --- a/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component/_config.js +++ b/packages/svelte/tests/custom-renderers/samples-dom/dom-child-component/_config.js @@ -2,11 +2,11 @@ import { test } from '../../test-dom.test'; export default test({ // this is the custom rendered component...it doesn't have anything inside because the part of the renderer - // responsible for the interleaving is the `before` function on the comment node which only push into `dom_elements` in this case + // responsible for the interleaving is the `before` function on the comment node which only push into `target.elements_children` in this case html: '', - test({ assert, dom_elements }) { - // we then get the element out of dom_elements - const [div] = dom_elements; + test({ assert, target }) { + // we then get the element out of target.elements_children + const [div] = target.children[0].elements_children; // check that is an actual DOM element and that it has the expected content assert.instanceOf(div, HTMLDivElement); assert.equal(div.outerHTML, '
hello from child
'); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/Child.svelte b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/Child.svelte new file mode 100644 index 0000000000..b7b75c2ddc --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/Child.svelte @@ -0,0 +1 @@ +something diff --git a/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/_config.js b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/_config.js new file mode 100644 index 0000000000..d4663e3a34 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test-dom.test'; + +export default test({ + hydrate: true, + test({ assert, component, target, warnings }) { + assert.instanceOf(target, HTMLElement); + assert.deepEqual(warnings, []); + assert.ok(target.querySelector('#keep-me')); + assert.ok(target.querySelector('custom-rendered')); + + component.hide(); + flushSync(); + + assert.ok(target.querySelector('#keep-me')); + assert.equal(target.querySelector('custom-rendered'), null); + assert.notInclude(target.textContent, 'Cool:'); + + component.show(); + flushSync(); + + assert.ok(target.querySelector('#keep-me')); + assert.ok(target.querySelector('custom-rendered')); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/main.svelte b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/main.svelte new file mode 100644 index 0000000000..623d7dabe5 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/hydration-interleaved-dom-custom/main.svelte @@ -0,0 +1,23 @@ + + + + +
+ {#if visible} + Cool: + {/if} +
+ +
diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/Child.svelte b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/Child.svelte new file mode 100644 index 0000000000..c8167faeaa --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/Child.svelte @@ -0,0 +1,7 @@ + + +
+ {message} +
diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/_config.js b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/_config.js new file mode 100644 index 0000000000..f94d531b16 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/_config.js @@ -0,0 +1,44 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test-dom.test'; + +export default test({ + test({ assert, component, target }) { + // we mounted the component in the root fragment so the div is in the target.elements_children + const [div] = target.elements_children; + assert.instanceOf(div, HTMLDivElement); + + // find the custom-rendered element in the div and assert the json content + let custom_rendered = div.querySelector('custom-rendered'); + assert.instanceOf(custom_rendered, HTMLElement); + assert.deepEqual(JSON.parse(custom_rendered.textContent), { + type: 'element', + name: 'div', + attributes: {}, + children: [ + { + type: 'element', + name: 'span', + attributes: {}, + children: [{ type: 'text', value: 'hello from child' }], + elements_children: [], + listeners: {} + } + ], + elements_children: [], + listeners: {} + }); + + component.hide(); + flushSync(); + + // we unmounted the custom rendered component so the map is empty again + assert.equal(div.querySelector('custom-rendered'), null); + + component.show(); + flushSync(); + + // we mounted the custom rendered component into the div again so it's in the mounted_in_dom_elements map again + custom_rendered = div.querySelector('custom-rendered'); + assert.instanceOf(custom_rendered, HTMLElement); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/main.svelte b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/main.svelte new file mode 100644 index 0000000000..ab5cc65d79 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child-extra-text/main.svelte @@ -0,0 +1,20 @@ + + + + +
+ {#if visible} + test: + {/if} +
diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/Child.svelte b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/Child.svelte new file mode 100644 index 0000000000..c8167faeaa --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/Child.svelte @@ -0,0 +1,7 @@ + + +
+ {message} +
diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/_config.js b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/_config.js new file mode 100644 index 0000000000..f94d531b16 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/_config.js @@ -0,0 +1,44 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test-dom.test'; + +export default test({ + test({ assert, component, target }) { + // we mounted the component in the root fragment so the div is in the target.elements_children + const [div] = target.elements_children; + assert.instanceOf(div, HTMLDivElement); + + // find the custom-rendered element in the div and assert the json content + let custom_rendered = div.querySelector('custom-rendered'); + assert.instanceOf(custom_rendered, HTMLElement); + assert.deepEqual(JSON.parse(custom_rendered.textContent), { + type: 'element', + name: 'div', + attributes: {}, + children: [ + { + type: 'element', + name: 'span', + attributes: {}, + children: [{ type: 'text', value: 'hello from child' }], + elements_children: [], + listeners: {} + } + ], + elements_children: [], + listeners: {} + }); + + component.hide(); + flushSync(); + + // we unmounted the custom rendered component so the map is empty again + assert.equal(div.querySelector('custom-rendered'), null); + + component.show(); + flushSync(); + + // we mounted the custom rendered component into the div again so it's in the mounted_in_dom_elements map again + custom_rendered = div.querySelector('custom-rendered'); + assert.instanceOf(custom_rendered, HTMLElement); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/main.svelte b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/main.svelte new file mode 100644 index 0000000000..ad36347f79 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples-dom/renderer-to-dom-child/main.svelte @@ -0,0 +1,20 @@ + + + + +
+ {#if visible} + + {/if} +
diff --git a/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js b/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js index a44fc4ae1f..2afc2d8024 100644 --- a/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/attribute-casing/_config.js @@ -1,8 +1,8 @@ import { test } from '../../test'; export default test({ - test({ assert, target }) { - const elements = target.children.filter((/** @type {any} */ n) => n.type === 'element'); + test({ assert, target, utils }) { + const elements = target.children.filter(utils.filter_elements()); assert.equal(elements.length, 4); diff --git a/packages/svelte/tests/custom-renderers/samples/attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js index 448b8adc26..814de0ab4b 100644 --- a/packages/svelte/tests/custom-renderers/samples/attributes/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js @@ -1,7 +1,7 @@ import { test } from '../../test'; export default test({ - test({ assert, target, serialize }) { + test({ assert, target, serialize, utils }) { const html = serialize(target); assert.equal( html, @@ -9,17 +9,13 @@ export default test({ ); // Verify individual attribute access on the object node - const div = target.children.find( - (/** @type {any} */ n) => n.type === 'element' && n.name === 'div' - ); + const div = target.children.find(utils.filter_elements((n) => n.name === 'div')); assert.ok(div); - assert.equal(div.attributes['class'], 'container'); - assert.equal(div.attributes['data-color'], 'red'); + 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' - ); + const span = div?.children.find(utils.filter_elements((n) => n.name === 'span')); assert.ok(span); - assert.equal(span.attributes['id'], 'label'); + assert.equal(span?.attributes['id'], 'label'); } }); 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 index e5fd87d389..1b52794a7b 100644 --- a/packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/default-value-spread/_config.js @@ -2,13 +2,9 @@ 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' - ); + test({ assert, target, dispatch_event, utils }) { + const inputs = target.children.filter(utils.filter_elements((n) => n.name === 'input')); + const button = target.children.find(utils.filter_elements((n) => n.name === 'button')); assert.equal(inputs.length, 4); assert.ok(button); 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 085ca682d8..b8c96b5d3d 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 @@ -2,19 +2,17 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - test({ assert, target, serialize, logs }) { - const button = target.children.find( - (/** @type {any} */ n) => n.type === 'element' && n.name === 'button' - ); + test({ assert, target, utils, logs }) { + const button = target.children.find(utils.filter_elements((n) => n.name === 'button')); assert.ok(button); - const listeners = button.listeners?.click; + 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. - for (const { handler } of listeners) { + for (const { handler } of listeners ?? []) { handler.call(button, { type: 'click' }, 'extra', 42); } flushSync(); 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 index ebcd14cc16..39522033b0 100644 --- a/packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/event-handler-spread/_config.js @@ -3,19 +3,17 @@ 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' - ); + test({ assert, target, serialize, logs, utils }) { + const button = target.children.find(utils.filter_elements((n) => n.name === 'button')); assert.ok(button); - const listeners = button.listeners?.click; + 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) { + for (const { handler } of listeners ?? []) { handler.call(button, { type: 'click' }, 'extra', 42); } flushSync(); diff --git a/packages/svelte/tests/custom-renderers/samples/select-option/_config.js b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js index 7e6e62edd7..f2f878a3eb 100644 --- a/packages/svelte/tests/custom-renderers/samples/select-option/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/select-option/_config.js @@ -2,23 +2,19 @@ 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' - ); + test({ assert, target, serialize, utils }) { + const select = target.children.find(utils.filter_elements((n) => 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'); + 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 options = select?.children.filter(utils.filter_elements((n) => 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( 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 4b839e5ad2..c5848dddd8 100644 --- a/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/special-attributes/_config.js @@ -2,14 +2,10 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - test({ assert, target, dispatch_event }) { + test({ assert, utils, target, dispatch_event }) { // Find all inputs and the button - 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' - ); + const inputs = target.children.filter(utils.filter_elements((n) => n.name === 'input')); + const button = target.children.find(utils.filter_elements((n) => n.name === 'button')); assert.equal(inputs.length, 5); assert.ok(button); 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 index 8d4602f7fb..2d01190da9 100644 --- a/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js +++ b/packages/svelte/tests/custom-renderers/samples/svelte-element-autofocus/_config.js @@ -1,18 +1,16 @@ import { test } from '../../test'; export default test({ - test({ assert, target }) { + test({ assert, target, utils }) { // 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' - ); + const input = target.children.find(utils.filter_elements((n) => n.name === 'input')); assert.ok(input, 'input element should exist'); assert.equal( - input.attributes['autofocus'], + 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'); + assert.equal(input?.attributes['value'], 'test', 'value should be set as a regular attribute'); } }); diff --git a/packages/svelte/tests/custom-renderers/shared.ts b/packages/svelte/tests/custom-renderers/shared.ts index 2593f8eb83..c5bbc06bf6 100644 --- a/packages/svelte/tests/custom-renderers/shared.ts +++ b/packages/svelte/tests/custom-renderers/shared.ts @@ -5,10 +5,18 @@ 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, dom_elements } from './renderer.js'; -import { mount, unmount } from '../../src/index-client.js'; +import renderer, { + create_root, + serialize, + dispatch_event, + type ObjFragment, + type ObjElement, + type ObjNode +} from './renderer.js'; import { writeFile } from 'node:fs/promises'; import { globSync } from 'tinyglobby'; +import { hydrate, unmount, mount } from 'svelte'; +import { render } from 'svelte/server'; // `_config.js` test callbacks rely on inferred parameter types, which // TypeScript treats as non-explicit and rejects for chai's assertion-function @@ -26,11 +34,13 @@ type NonAssertingMethods = { type Assert = Omit & NonAssertingMethods; -export interface CustomRendererTest extends BaseTest { +interface CustomRendererHydrateTest extends BaseTest { html?: string; compileOptions?: Partial; props?: Record; + server_props?: Record; context?: Map; + hydrate: true; error?: string; compile_error?: string; compile_warnings?: false; @@ -38,7 +48,7 @@ export interface CustomRendererTest extends BaseTest { warnings?: string[]; test?: (args: { assert: Assert; - target: any; + target: HTMLElement; component: Record; mod: any; logs: any[]; @@ -46,10 +56,48 @@ export interface CustomRendererTest extends BaseTest { renderer: typeof renderer; serialize: typeof serialize; dispatch_event: typeof dispatch_event; - dom_elements: Array; }) => void | Promise; } +function filter_elements(extra_filter?: (node: ObjElement) => boolean) { + return (node: ObjNode): node is ObjElement => + node.type === 'element' && (extra_filter?.(node) ?? true); +} + +const utils = { + filter_elements +}; + +interface CustomRendererNonHydrateTest extends BaseTest { + html?: string; + compileOptions?: Partial; + props?: Record; + server_props?: Record; + context?: Map; + hydrate?: false; + error?: string; + compile_error?: string; + compile_warnings?: false; + runtime_error?: string; + warnings?: string[]; + test?: (args: { + utils: { + filter_elements: typeof filter_elements; + }; + assert: Assert; + target: ObjFragment; + component: Record; + mod: any; + logs: any[]; + warnings: any[]; + renderer: typeof renderer; + serialize: typeof serialize; + dispatch_event: typeof dispatch_event; + }) => void | Promise; +} + +export type CustomRendererTest = CustomRendererHydrateTest | CustomRendererNonHydrateTest; + // eslint-disable-next-line no-console const console_log = console.log; // eslint-disable-next-line no-console @@ -97,6 +145,10 @@ async function common_setup( try { await compile_directory(cwd, 'client', compile_options); + + if (config.hydrate) { + await compile_directory(cwd, 'server', compile_options); + } } catch (err) { if (config.compile_error) { assert.include((err as Error).message, config.compile_error); @@ -165,11 +217,16 @@ async function run_test(cwd: string, config: CustomRendererTest, compile_options try { const mod = await import(`${cwd}/_output/client/main.svelte.js`); + + if (config.hydrate) { + await run_hydration_test(cwd, config, mod, logs, warnings); + return; + } + const target = create_root(); let component: Record | undefined; try { - dom_elements.length = 0; component = mount(mod.default, { renderer, target, @@ -209,16 +266,16 @@ async function run_test(cwd: string, config: CustomRendererTest, compile_options try { if (config.test) { await config.test({ + utils, assert, - target, + target: target as never, component: component ?? {}, mod, logs, warnings, renderer: renderer, serialize, - dispatch_event, - dom_elements + dispatch_event }); } @@ -254,6 +311,49 @@ async function run_test(cwd: string, config: CustomRendererTest, compile_options } } +async function run_hydration_test( + cwd: string, + config: CustomRendererTest, + mod: any, + logs: any[], + warnings: any[] +) { + const target = document.createElement('main'); + const rendered = await render((await import(`${cwd}/_output/server/main.svelte.js`)).default, { + props: config.server_props ?? config.props ?? {} + }); + target.innerHTML = rendered.body; + + const component = hydrate(mod.default, { + target, + props: config.props ?? {}, + context: config.context + }); + + if (config.html) { + assert.equal(target.innerHTML, config.html); + } + + try { + if (config.test) { + await config.test({ + utils, + assert, + target: target as never, + component, + mod, + logs, + warnings, + renderer: renderer, + serialize, + dispatch_event + }); + } + } finally { + unmount(component); + } +} + export function ok(value: any): asserts value { if (!value) { throw new Error(`Expected truthy value, got ${value}`); diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/Component.svelte b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/Component.svelte new file mode 100644 index 0000000000..28b982ee94 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/Component.svelte @@ -0,0 +1,5 @@ + + +{@render children?.()} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_config.js b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_config.js new file mode 100644 index 0000000000..135918fc82 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + experimental: { + // The actual renderer module is irrelevant for this snapshot — we only + // care that the server output is a no-op while the client output still + // imports/uses the custom renderer. Using a fixed path keeps the + // snapshot stable across machines. + customRenderer: 'my-custom-renderer' + } + } +}); diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/Component.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/Component.svelte.js new file mode 100644 index 0000000000..5571be6d8d --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/Component.svelte.js @@ -0,0 +1,13 @@ +import $renderer from 'my-custom-renderer'; +import 'svelte/internal/disclose-version'; +import * as $ from 'svelte/internal/client'; + +export default function Component($$anchor, $$props) { + var $$pop_renderer = $.push_renderer($renderer); + var fragment = $.comment(); + var node = $.first_child(fragment); + + $.snippet(node, () => $.validate_snippet_renderer($renderer, $$props.children) ?? $.noop); + $.append($$anchor, fragment); + $$pop_renderer(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/main.svelte.js new file mode 100644 index 0000000000..ff224836bc --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/client/main.svelte.js @@ -0,0 +1,15 @@ +import $renderer from 'my-custom-renderer'; +import 'svelte/internal/disclose-version'; +import 'svelte/internal/flags/legacy'; +import * as $ from 'svelte/internal/client'; +import Component from "./Component.svelte"; + +export default function Main($$anchor) { + var $$pop_renderer = $.push_renderer($renderer); + var fragment = $.comment(); + var node = $.first_child(fragment); + + Component(node, {}); + $.append($$anchor, fragment); + $$pop_renderer(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/Component.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/Component.svelte.js new file mode 100644 index 0000000000..e280e2d21d --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/Component.svelte.js @@ -0,0 +1 @@ +export default function Component() {} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/main.svelte.js b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/main.svelte.js new file mode 100644 index 0000000000..2b19de8d86 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/_expected/server/main.svelte.js @@ -0,0 +1 @@ +export default function Main() {} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/main.svelte b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/main.svelte new file mode 100644 index 0000000000..ccf0870c03 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/custom-renderer-single-node/main.svelte @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 378218aa44..d30243c3dc 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2621,12 +2621,19 @@ declare module 'svelte/reactivity/window' { } declare module 'svelte/renderer' { - export function createRenderer = DefaultNodes, TFragment extends object = T extends DefaultNodes ? object : T["fragment"], TElement extends object = T extends DefaultNodes ? object : T["element"], TTextNode extends object = T extends DefaultNodes ? object : T["text"], TComment extends object = T extends DefaultNodes ? object : T["comment"], R extends Renderer = Renderer>(renderer: R): R; + export function createRenderer = DefaultNodes, TFragment extends object = T extends DefaultNodes ? object : T["fragment"], TElement extends object = T extends DefaultNodes ? object : T["element"], TTextNode extends object = T extends DefaultNodes ? object : T["text"], TComment extends object = T extends DefaultNodes ? object : T["comment"], TForeignNodes extends RendererNodes | undefined = T extends DefaultNodes ? RendererNodes : T["foreign"], R extends Renderer = Renderer>(renderer: R): R; type Renderer< TFragment extends object = object, 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 @@ -2707,27 +2714,89 @@ declare module 'svelte/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; + 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; }; - type NodeType = keyof RendererNodes; + 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) type UnsetObject = object & { readonly __unset: unique symbol }; type DefaultNodes = RendererNodes;