pull/18405/merge
Paolo Ricciuti 1 day ago committed by GitHub
commit e8c7ed925c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 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 ### rune_outside_svelte
``` ```

@ -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 > 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 ## rune_outside_svelte
> The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files

@ -588,13 +588,23 @@ export function client_component(analysis, options) {
if (custom_renderer !== undefined) { if (custom_renderer !== undefined) {
// when the custom renderer feature is enabled every component pushes a renderer: components // when the custom renderer feature is enabled every component pushes a renderer: components
// with a renderer module push `$renderer`, DOM components push `null` // 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( component_block.body.unshift(
b.var( b.var(
'$$pop_renderer', '$$pop_renderer',
b.call('$.push_renderer', custom_renderer ? b.id('$renderer') : b.literal(null)) 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) { if (state.events.size > 0) {

@ -45,10 +45,14 @@ export function RenderTag(node, context) {
); );
if (node.metadata.dynamic) { if (node.metadata.dynamic) {
// In custom renderer components, validate that the snippet is compatible // In custom-renderer-aware builds, validate that the snippet is compatible
// with the current renderer before rendering it // with the current renderer before rendering it.
if (custom_renderer) { if (custom_renderer !== undefined) {
snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function); 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 // 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 { } else {
// In custom renderer components, validate that the snippet is compatible // In custom-renderer-aware builds, validate that the snippet is compatible
// with the current renderer before rendering it // with the current renderer before rendering it.
if (custom_renderer) { if (custom_renderer !== undefined) {
snippet_function = b.call('$.validate_snippet_renderer', b.id('$renderer'), snippet_function); snippet_function = b.call(
'$.validate_snippet_renderer',
custom_renderer ? b.id('$renderer') : b.literal(null),
snippet_function
);
} }
statements.push( statements.push(

@ -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.call('$.wrap_snippet', b.id(context.state.analysis.name), b.function(null, args, body))
: b.arrow(args, body); : b.arrow(args, body);
// wrap snippets in components with a custom renderer so they can only be // In custom-renderer-aware builds, preserve the renderer that compiled the snippet.
// rendered by the same renderer that compiled them if (custom_renderer !== undefined) {
if (custom_renderer) { snippet = b.call(
snippet = b.call('$.renderer_snippet', b.id('$renderer'), snippet); '$.renderer_snippet',
custom_renderer ? b.id('$renderer') : b.literal(null),
snippet
);
} }
const declaration = b.const(node.expression, snippet); const declaration = b.const(node.expression, snippet);

@ -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) ? b.call('$.wrap_snippet', b.id(context.state.analysis.name), slot_fn)
: slot_fn; : slot_fn;
if (custom_renderer) { if (custom_renderer !== undefined) {
children_fn = b.call('$.renderer_snippet', b.id('$renderer'), children_fn); children_fn = b.call(
'$.renderer_snippet',
custom_renderer ? b.id('$renderer') : b.literal(null),
children_fn
);
} }
// create `children` prop... // create `children` prop...

@ -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 check_graph_for_cycles from '../2-analyze/utils/check_graph_for_cycles.js';
import is_reference from 'is-reference'; import is_reference from 'is-reference';
import { set_scope } from '../scope.js'; import { set_scope } from '../scope.js';
import { custom_renderer } from '../../state.js';
/** /**
* Match Svelte 4 behaviour by sorting ConstTag nodes in topological order * Match Svelte 4 behaviour by sorting ConstTag nodes in topological order
@ -290,7 +291,8 @@ export function clean_nodes(
!first.metadata.dynamic && !first.metadata.dynamic &&
!first.attributes.some( !first.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--') (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 */ /** 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: is_text_first:
(parent.type === 'Fragment' || (parent.type === 'Fragment' ||

@ -6,7 +6,8 @@
* @template {object} [TElement=T extends DefaultNodes ? object : T['element']] * @template {object} [TElement=T extends DefaultNodes ? object : T['element']]
* @template {object} [TTextNode=T extends DefaultNodes ? object : T['text']] * @template {object} [TTextNode=T extends DefaultNodes ? object : T['text']]
* @template {object} [TComment=T extends DefaultNodes ? object : T['comment']] * @template {object} [TComment=T extends DefaultNodes ? object : T['comment']]
* @template {Renderer<TFragment, TElement, TTextNode, TComment>} [R=Renderer<TFragment, TElement, TTextNode, TComment>] * @template {RendererNodes<any, any, any, any, any> | undefined} [TForeignNodes=T extends DefaultNodes ? RendererNodes<any, any, any, any, any> : T['foreign']]
* @template {Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes>} [R=Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes>]
* @param {R} renderer * @param {R} renderer
* @returns {R} * @returns {R}
*/ */

@ -7,6 +7,14 @@ import { hydrating, set_hydrating } from '../dom/hydration.js';
*/ */
export let current_renderer = null; 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<any, any, any, any> | null}
*/
export let parent_renderer = null;
/** /**
* @param {Renderer<any, any, any, any> | null} value * @param {Renderer<any, any, any, any> | null} value
*/ */
@ -14,11 +22,21 @@ export function set_renderer(value) {
current_renderer = value; current_renderer = value;
} }
/**
* @param {Renderer<any, any, any, any> | null} value
*/
export function set_parent_renderer(value) {
parent_renderer = value;
}
/** /**
* *
* @param {Renderer<any, any, any, any> | null} value * @param {Renderer<any, any, any, any> | null} value
* @param {Renderer<any, any, any, any> | 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 previous_hydrating = hydrating;
var should_disable_hydration = hydrating && value != null; var should_disable_hydration = hydrating && value != null;
// this is to allow hydration code to treeshake // this is to allow hydration code to treeshake
@ -26,10 +44,13 @@ export function push_renderer(value) {
set_hydrating(false); set_hydrating(false);
} }
var previous_renderer = current_renderer; var previous_renderer = current_renderer;
var previous_parent_renderer = parent_renderer;
parent_renderer = parent;
current_renderer = value; current_renderer = value;
return () => { return () => {
current_renderer = previous_renderer; current_renderer = previous_renderer;
parent_renderer = previous_parent_renderer;
if (should_disable_hydration) { if (should_disable_hydration) {
set_hydrating(previous_hydrating); set_hydrating(previous_hydrating);
} }

@ -3,6 +3,13 @@ export type Renderer<
TElement extends object = object, TElement extends object = object,
TTextNode extends object = object, TTextNode extends object = object,
TComment extends object = object, TComment extends object = object,
TForeignNodes extends RendererNodes<any, any, any, any, any> | undefined = RendererNodes<
any,
any,
any,
any,
any
>,
TNode extends TFragment | TElement | TTextNode | TComment = TNode extends TFragment | TElement | TTextNode | TComment =
| TFragment | TFragment
| TElement | TElement
@ -83,26 +90,88 @@ export type Renderer<
/** Remove an event listener of the given type and handler from the target node. */ /** Remove an event listener of the given type and handler from the target node. */
removeEventListener(target: TElement, type: string, handler: any, options?: any): void; 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<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment'],
element: TNode,
anchor:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['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<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment'],
anchor:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment']
| null
): void;
/** Remove a node that was inserted across renderer boundaries. */
removeForeign(
node:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment']
): void;
/** Remove a node that was inserted across renderer boundaries. */
removeFromForeign(node: TNode): void;
};
}; };
type DefinedRendererNodes<TNodes extends RendererNodes<any, any, any, any, any> | undefined> =
TNodes extends RendererNodes<any, any, any, any, any>
? TNodes
: RendererNodes<any, any, any, any, any>;
export type RendererNodes< export type RendererNodes<
Fragment extends object, Fragment extends object,
Element extends object, Element extends object,
TextNode extends object, TextNode extends object,
Comment extends object Comment extends object,
ForeignNode extends RendererNodes<any, any, any, any, any> = RendererNodes<
any,
any,
any,
any,
any
>
> = { > = {
fragment: Fragment; fragment: Fragment;
element: Element; element: Element;
text: TextNode; text: TextNode;
comment: Comment; comment: Comment;
foreign?: ForeignNode;
}; };
export type NodeType = keyof RendererNodes<any, any, any, any>; export type NodeType = Exclude<keyof RendererNodes<any, any, any, any, any>, 'foreign'>;
// to detect if the user is passing a type or not we create this type utils that adds a unique symbol // 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 // 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 // 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 // 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 UnsetObject = object & { readonly __unset: unique symbol };
export type DefaultNodes = RendererNodes<UnsetObject, UnsetObject, UnsetObject, UnsetObject>; export type DefaultNodes = RendererNodes<UnsetObject, UnsetObject, UnsetObject, UnsetObject>;

@ -60,7 +60,7 @@ export function hmr(fn) {
// Forward the start/end DOM nodes from the inner effect to the outer active effect // Forward the start/end DOM nodes from the inner effect to the outer active effect
// which would get them if the HMR wrapper wasn't there. Do this inside the block not // which would get them if the HMR wrapper wasn't there. Do this inside the block not
// outside so that HMR updates to the component will also update the nodes on the // outside so that HMR updates to the component will also update the nodes on the
// active effect. We copy only start/end, not the full nodes object, so that // active effect. We copy only the removal ranges, not the full nodes object, so that
// pause_children does not collect transitions from both effects and fire outroend twice. // pause_children does not collect transitions from both effects and fire outroend twice.
var inner_nodes = effect.nodes; var inner_nodes = effect.nodes;
if (inner_nodes) { if (inner_nodes) {
@ -68,8 +68,15 @@ export function hmr(fn) {
if (ae.nodes) { if (ae.nodes) {
ae.nodes.start = inner_nodes.start; ae.nodes.start = inner_nodes.start;
ae.nodes.end = inner_nodes.end; ae.nodes.end = inner_nodes.end;
ae.nodes.segments = inner_nodes.segments;
} else { } else {
ae.nodes = { start: inner_nodes.start, end: inner_nodes.end, a: null, t: null }; ae.nodes = {
start: inner_nodes.start,
end: inner_nodes.end,
segments: inner_nodes.segments,
a: null,
t: null
};
} }
} }
}, EFFECT_TRANSPARENT); }, EFFECT_TRANSPARENT);

@ -275,7 +275,7 @@ export class Boundary {
this.#pending_effect = branch(() => pending(this.#anchor)); this.#pending_effect = branch(() => pending(this.#anchor));
queue_micro_task(() => { queue_micro_task(() => {
var pop_renderer = push_renderer(this.#effect.r); var pop_renderer = push_renderer(this.#effect.r, this.#effect.pr);
try { try {
var fragment = (this.#offscreen_fragment = create_fragment()); var fragment = (this.#offscreen_fragment = create_fragment());
@ -389,7 +389,7 @@ export class Boundary {
set_active_reaction(this.#effect); set_active_reaction(this.#effect);
set_component_context(this.#effect.ctx); 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 { try {
Batch.ensure(); Batch.ensure();
@ -430,10 +430,13 @@ export class Boundary {
} }
if (this.#offscreen_fragment) { 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); try {
this.#offscreen_fragment = null; insert_before(this.#anchor, this.#offscreen_fragment);
pop_renderer?.(); this.#offscreen_fragment = null;
} finally {
pop_renderer?.();
}
} }
} }
} }

@ -20,7 +20,13 @@ import {
get_last_child get_last_child
} from '../operations.js'; } from '../operations.js';
import { DEV } from 'esm-env'; 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 * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@ -79,6 +85,12 @@ export class BranchManager {
*/ */
#renderer = null; #renderer = null;
/**
* The parent renderer that was active when this BranchManager was created.
* @type {Renderer | null}
*/
#parent_renderer = null;
/** /**
* @param {TemplateNode} anchor * @param {TemplateNode} anchor
* @param {boolean} transition * @param {boolean} transition
@ -87,6 +99,24 @@ export class BranchManager {
this.anchor = anchor; this.anchor = anchor;
this.#transition = transition; this.#transition = transition;
this.#renderer = current_renderer; 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 batch was made obsolete, bail
if (!this.#batches.has(batch)) return; if (!this.#batches.has(batch)) return;
var pop_renderer = push_renderer(this.#renderer); var pop_renderer = push_renderer(this.#renderer, this.#parent_renderer);
try { try {
var key = /** @type {Key} */ (this.#batches.get(batch)); var key = /** @type {Key} */ (this.#batches.get(batch));
@ -220,13 +250,13 @@ export class BranchManager {
append_child(fragment, target); append_child(fragment, target);
this.#offscreen.set(key, { this.#offscreen.set(key, {
effect: branch(() => fn(target)), effect: this.#create_branch(() => fn(target)),
fragment fragment
}); });
} else { } else {
this.#onscreen.set( this.#onscreen.set(
key, key,
branch(() => fn(this.anchor)) this.#create_branch(() => fn(this.anchor))
); );
} }
} }

@ -50,7 +50,12 @@ import { current_batch } from '../../reactivity/batch.js';
import * as e from '../../errors.js'; import * as e from '../../errors.js';
import { tag } from '../../dev/tracing.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: // When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
@ -220,11 +225,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; 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) { if (is_controlled) {
var parent_node = /** @type {Element} */ (node); var parent_node = /** @type {Element} */ (node);
@ -233,6 +233,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
: /** @type {Text} */ (append_child(parent_node, create_text())); : /** @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) { if (hydrating) {
hydrate_next(); hydrate_next();
} }
@ -271,7 +276,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
return; return;
} }
var pop_renderer = push_renderer(renderer); var pop_renderer = push_renderer(renderer, parent);
try { try {
state.pending.delete(batch); state.pending.delete(batch);
@ -308,6 +313,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
state.pending.delete(batch); 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(() => { var effect = block(() => {
array = /** @type {V[]} */ (get(each_array)); array = /** @type {V[]} */ (get(each_array));
var length = array.length; var length = array.length;
@ -441,6 +450,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
// will now be `CLEAN`. // will now be `CLEAN`.
get(each_array); 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} */ /** @type {EachState} */
var state = { effect, flags, items, pending, outrogroups: null, fallback }; var state = { effect, flags, items, pending, outrogroups: null, fallback };

@ -15,8 +15,9 @@ import * as e from '../../errors.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { get_first_child, get_next_sibling, insert_before, node_type } from '../operations.js'; import { get_first_child, get_next_sibling, insert_before, node_type } from '../operations.js';
import { prevent_snippet_stringification } from '../../../shared/validate.js'; import { prevent_snippet_stringification } from '../../../shared/validate.js';
import { has_own_property } from '../../../shared/utils.js';
import { BranchManager } from './branches.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 * @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn
@ -35,7 +36,26 @@ export function snippet(node, get_snippet, ...args) {
e.invalid_snippet(); 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); }, EFFECT_TRANSPARENT);
} }
@ -98,7 +118,16 @@ export function renderer_snippet(expected_renderer, fn) {
* @returns {T} * @returns {T}
*/ */
export function validate_snippet_renderer(expected_renderer, fn) { 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(); e.snippet_renderer_mismatch();
} }
return fn; return fn;

@ -64,7 +64,7 @@ export function head(hash, render_fn) {
if (!hydrating) { if (!hydrating) {
if (e.nodes === null) { if (e.nodes === null) {
e.nodes = { start: anchor, end: anchor, a: null, t: null }; e.nodes = { start: anchor, end: anchor, segments: null, a: null, t: null };
} else { } else {
e.nodes.end = anchor; e.nodes.end = anchor;
} }

@ -1,4 +1,5 @@
/** @import { Effect, TemplateNode } from '#client' */ /** @import { Effect, TemplateNode } from '#client' */
/** @import { Renderer } from '../custom-renderer/types.js' */
import { hydrate_node, hydrating, reset, set_hydrate_node } from './hydration.js'; import { hydrate_node, hydrating, reset, set_hydrate_node } from './hydration.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { init_array_prototype_warnings } from '../dev/equality.js'; import { init_array_prototype_warnings } from '../dev/equality.js';
@ -17,7 +18,8 @@ import {
} from '#client/constants'; } from '#client/constants';
import { eager_block_effects } from '../reactivity/batch.js'; import { eager_block_effects } from '../reactivity/batch.js';
import { NAMESPACE_HTML } from '../../../constants.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 // export these for reference in the compiled code, making global name deduplication unnecessary
/** @type {Window} */ /** @type {Window} */
@ -427,12 +429,50 @@ export function append_child(parent, child) {
* @param {Node} new_node * @param {Node} new_node
*/ */
export function insert_before(ref_node, new_node) { export function insert_before(ref_node, new_node) {
if (current_renderer) { var renderer = current_renderer;
var parent = current_renderer.getParent(ref_node); var parent = parent_renderer;
current_renderer.insert(parent, new_node, ref_node);
if (renderer === null && parent === null) {
// DOM into DOM
ref_node.before(new_node);
return; 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<typeof renderer>} */ (renderer).getParent(ref_node);
/** @type {NonNullable<typeof renderer>} */ (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<typeof renderer>} */ (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;
} }
/** /**
@ -454,11 +494,50 @@ export function insert_after(ref_node, new_node) {
* @param {ChildNode} node * @param {ChildNode} node
*/ */
export function remove_node(node) { export function remove_node(node) {
if (current_renderer) { var renderer = current_renderer;
current_renderer.remove(node); var parent = parent_renderer;
if (renderer === null && parent === null) {
// DOM from DOM
node.remove();
return; 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<typeof renderer>} */ (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<typeof renderer>} */ (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;
} }
/** /**

@ -15,7 +15,7 @@ import {
TEMPLATE_USE_MATHML, TEMPLATE_USE_MATHML,
TEMPLATE_USE_SVG TEMPLATE_USE_SVG
} from '../../../constants.js'; } 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 { active_effect } from '../runtime.js';
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
import { import {
@ -50,10 +50,36 @@ const SCRIPT_TAG = IS_XHTML ? 'script' : 'SCRIPT';
export function assign_nodes(start, end) { export function assign_nodes(start, end) {
var effect = /** @type {Effect} */ (active_effect); var effect = /** @type {Effect} */ (active_effect);
if (effect.nodes === null) { 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 {string} content
* @param {number} flags * @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. // 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) { if ((effect.f & REACTION_RAN) === 0 || effect.nodes.end === null) {
effect.nodes.end = hydrate_node; 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(); hydrate_next();

@ -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 * The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
* @param {string} rune * @param {string} rune

@ -8,7 +8,12 @@ import {
set_component_context, set_component_context,
set_dev_stack set_dev_stack
} from '../context.js'; } from '../context.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 { invoke_error_boundary } from '../error-handling.js';
import { import {
active_effect, active_effect,
@ -132,6 +137,7 @@ export function capture() {
var previous_component_context = component_context; var previous_component_context = component_context;
var previous_batch = /** @type {Batch} */ (current_batch); var previous_batch = /** @type {Batch} */ (current_batch);
var previous_renderer = current_renderer; var previous_renderer = current_renderer;
var previous_parent_renderer = parent_renderer;
if (DEV) { if (DEV) {
var previous_dev_stack = dev_stack; var previous_dev_stack = dev_stack;
@ -143,6 +149,7 @@ export function capture() {
set_component_context(previous_component_context); set_component_context(previous_component_context);
set_renderer(previous_renderer); set_renderer(previous_renderer);
set_parent_renderer(previous_parent_renderer);
if (activate_batch && (previous_effect.f & DESTROYED) === 0) { if (activate_batch && (previous_effect.f & DESTROYED) === 0) {
// TODO we only need optional chaining here because `{#await ...}` blocks // TODO we only need optional chaining here because `{#await ...}` blocks
@ -297,6 +304,7 @@ export function unset_context(deactivate_batch = true) {
set_active_reaction(null); set_active_reaction(null);
set_component_context(null); set_component_context(null);
set_renderer(null); set_renderer(null);
set_parent_renderer(null);
if (deactivate_batch) current_batch?.deactivate(); if (deactivate_batch) current_batch?.deactivate();
if (DEV) { if (DEV) {

@ -47,7 +47,7 @@ import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten } from './async.js'; import { flatten } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.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 * @param {'$effect' | '$effect.pre' | '$inspect'} rune
@ -116,7 +116,8 @@ function create_effect(type, fn) {
teardown: null, teardown: null,
wv: 0, wv: 0,
ac: null, ac: null,
r: current_renderer r: current_renderer,
pr: parent_renderer
}; };
if (DEV) { if (DEV) {
@ -520,60 +521,63 @@ export function destroy_block_effect_children(signal) {
export function destroy_effect(effect, remove_dom = true) { export function destroy_effect(effect, remove_dom = true) {
var removed = false; var removed = false;
var pop_renderer = push_renderer(effect.r); var pop_renderer = push_renderer(effect.r, effect.pr);
if ( try {
(remove_dom || (effect.f & HEAD_EFFECT) !== 0) && if (
effect.nodes !== null && (remove_dom || (effect.f & HEAD_EFFECT) !== 0) &&
effect.nodes.end !== null effect.nodes !== null &&
) { effect.nodes.end !== null
remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end)); ) {
removed = true; remove_effect_nodes(effect);
} removed = true;
}
effect.f |= DESTROYING; effect.f |= DESTROYING;
destroy_effect_children(effect, remove_dom && !removed); destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0); remove_reactions(effect, 0);
var transitions = effect.nodes && effect.nodes.t; var transitions = effect.nodes && effect.nodes.t;
if (transitions !== null) { if (transitions !== null) {
for (const transition of transitions) { for (const transition of transitions) {
transition.stop(); transition.stop();
}
} }
}
execute_effect_teardown(effect); execute_effect_teardown(effect);
effect.f ^= DESTROYING; effect.f ^= DESTROYING;
effect.f |= DESTROYED; effect.f |= DESTROYED;
var parent = effect.parent; var parent = effect.parent;
// If the parent doesn't have any children, then skip this work altogether
if (parent !== null && parent.first !== null) {
unlink_effect(effect);
}
if (DEV) { // If the parent doesn't have any children, then skip this work altogether
effect.component_function = null; if (parent !== null && parent.first !== null) {
} unlink_effect(effect);
}
// `first` and `child` are nulled out in destroy_effect_children if (DEV) {
// we don't null out `parent` so that error propagation can work correctly effect.component_function = null;
effect.next = }
effect.prev =
effect.teardown =
effect.ctx =
effect.deps =
effect.fn =
effect.nodes =
effect.ac =
effect.b =
effect.r =
null;
pop_renderer?.(); // `first` and `child` are nulled out in destroy_effect_children
// we don't null out `parent` so that error propagation can work correctly
effect.next =
effect.prev =
effect.teardown =
effect.ctx =
effect.deps =
effect.fn =
effect.nodes =
effect.ac =
effect.b =
effect.r =
effect.pr =
null;
} finally {
pop_renderer?.();
}
} }
/** /**
@ -591,6 +595,26 @@ export function remove_effect_dom(node, end) {
} }
} }
/**
* @param {Effect} effect
*/
function remove_effect_nodes(effect) {
var nodes = /** @type {NonNullable<Effect['nodes']>} */ (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 * Detach an effect from the effect tree, freeing up memory and
* reducing the amount of work that happens on subsequent traversals * reducing the amount of work that happens on subsequent traversals
@ -751,7 +775,7 @@ export function aborted(effect = /** @type {Effect} */ (active_effect)) {
export function move_effect(effect, fragment) { export function move_effect(effect, fragment) {
if (!effect.nodes) return; if (!effect.nodes) return;
var pop_renderer = push_renderer(effect.r); var pop_renderer = push_renderer(effect.r, effect.pr);
try { try {
/** @type {TemplateNode | null} */ /** @type {TemplateNode | null} */

@ -65,12 +65,21 @@ export interface Derived<V = unknown> extends Value<V>, Reaction {
export interface EffectNodes { export interface EffectNodes {
start: TemplateNode; start: TemplateNode;
end: TemplateNode | null; end: TemplateNode | null;
/** Renderer-local ranges, used when a component interleaves custom and foreign-rendered nodes */
segments: null | EffectNodeSegment[];
/** $.animation */ /** $.animation */
a: AnimationManager | null; a: AnimationManager | null;
/** $.transition */ /** $.transition */
t: TransitionManager[] | null; t: TransitionManager[] | null;
} }
export interface EffectNodeSegment {
start: TemplateNode;
end: TemplateNode | null;
r: Renderer | null;
pr: Renderer | null;
}
export interface Effect extends Reaction { export interface Effect extends Reaction {
/** /**
* Branch effects store their start/end nodes so that they can be * Branch effects store their start/end nodes so that they can be
@ -97,6 +106,8 @@ export interface Effect extends Reaction {
b: Boundary | null; b: Boundary | null;
/** The renderer this effect was created with */ /** The renderer this effect was created with */
r: Renderer | null; r: Renderer | null;
/** The parent renderer this effect was created with */
pr: Renderer | null;
/** Dev only */ /** Dev only */
component_function?: any; component_function?: any;
/** Dev only. Only set for certain block effects. Contains a reference to the stack that represents the render tree */ /** Dev only. Only set for certain block effects. Contains a reference to the stack that represents the render tree */

@ -168,7 +168,7 @@ const listeners = new Map();
*/ */
function _mount(Component, options) { function _mount(Component, options) {
if (options.renderer) { if (options.renderer) {
var pop_renderer = push_renderer(options.renderer); var pop_renderer = push_renderer(options.renderer, options.renderer);
try { try {
return _mount_inner(Component, options); return _mount_inner(Component, options);

@ -468,7 +468,7 @@ export function update_effect(effect) {
active_effect = effect; active_effect = effect;
is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts 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) { if (DEV) {
var previous_component_fn = dev_current_component_function; var previous_component_fn = dev_current_component_function;

@ -8,11 +8,12 @@
import { createRenderer } from '../../src/renderer/index.js'; import { createRenderer } from '../../src/renderer/index.js';
type ObjElement = { export type ObjElement = {
type: 'element'; type: 'element';
name: string; name: string;
attributes: Record<string, string>; attributes: Record<string, string>;
children: ObjNode[]; children: ObjNode[];
elements_children: Array<HTMLElement | DocumentFragment | Text | Comment>;
listeners: Record<string, Array<{ handler: any; options?: any }>>; listeners: Record<string, Array<{ handler: any; options?: any }>>;
parent: ObjNode | null; parent: ObjNode | null;
}; };
@ -21,10 +22,14 @@ type ObjComment = {
type: 'comment'; type: 'comment';
value: string; value: string;
parent: ObjNode | null; parent: ObjNode | null;
before: (node: any) => void;
}; };
type ObjFragment = { type: 'fragment'; children: ObjNode[]; parent: ObjNode | null }; export type ObjFragment = {
type ObjNode = ObjElement | ObjText | ObjComment | ObjFragment; type: 'fragment';
children: ObjNode[];
parent: ObjNode | null;
elements_children: Array<HTMLElement | DocumentFragment | Text | Comment>;
};
export type ObjNode = ObjElement | ObjText | ObjComment | ObjFragment;
function insert_node( function insert_node(
parent: ObjNode & { children?: ObjNode[] }, parent: ObjNode & { children?: ObjNode[] },
@ -32,6 +37,10 @@ function insert_node(
anchor: ObjNode | null anchor: ObjNode | null
) { ) {
if (node.type === 'fragment') { if (node.type === 'fragment') {
if (parent.type === 'element' || parent.type === 'fragment') {
parent.elements_children = node.elements_children;
}
const children = [...(node.children ?? [])]; const children = [...(node.children ?? [])];
for (const child of children) { for (const child of children) {
insert_node(parent, child, anchor); insert_node(parent, child, anchor);
@ -69,19 +78,31 @@ function remove_from_parent(node: ObjNode) {
children.splice(idx, 1); children.splice(idx, 1);
} }
export const dom_elements: Array<DocumentFragment | Node> = []; const mounted_in_dom_elements = new Map<ObjNode, DocumentFragment | Node>();
const mounted = new Map<
HTMLElement | DocumentFragment | Text | Comment,
ObjElement | ObjFragment
>();
const renderer = createRenderer<{ const renderer = createRenderer<{
fragment: ObjFragment; fragment: ObjFragment;
element: ObjElement; element: ObjElement;
text: ObjText; text: ObjText;
comment: ObjComment; comment: ObjComment;
foreign: {
comment: Comment;
element: HTMLElement;
text: Text;
fragment: DocumentFragment;
};
}>({ }>({
createFragment() { createFragment() {
return { return {
type: 'fragment', type: 'fragment',
children: [], children: [],
parent: null parent: null,
elements_children: []
}; };
}, },
@ -92,7 +113,8 @@ const renderer = createRenderer<{
attributes: {}, attributes: {},
children: [], children: [],
listeners: {}, listeners: {},
parent: null parent: null,
elements_children: []
}; };
}, },
@ -108,12 +130,7 @@ const renderer = createRenderer<{
return { return {
type: 'comment', type: 'comment',
value: data, value: data,
parent: null, 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);
}
}; };
}, },
@ -189,6 +206,35 @@ const renderer = createRenderer<{
target.listeners[type] = target.listeners[type].filter( target.listeners[type] = target.listeners[type].filter(
(/** @type {any} */ l) => l.handler !== handler (/** @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);
}
} }
}); });

@ -0,0 +1,9 @@
<svelte:options customRenderer={null} />
<script>
let { message } = $props();
</script>
<div>
<span>{message}</span>
</div>

@ -0,0 +1,16 @@
import { flushSync } from 'svelte';
import { test } from '../../test-dom.test';
export default test({
html: '<custom></custom>',
async test({ assert, component, target }) {
const [div] = target.children[0].elements_children;
assert.instanceOf(div, HTMLDivElement);
assert.equal(div.outerHTML, '<div><span>hello from child</span></div>');
component.hide();
flushSync();
assert.isFalse(target.children[0].elements_children.includes(div));
}
});

@ -0,0 +1,15 @@
<script>
import Child from './Child.svelte';
let visible = $state(true);
export function hide() {
visible = false;
}
</script>
<custom>
{#if visible}
<Child message="hello from child"></Child>
{/if}
</custom>

@ -2,11 +2,11 @@ import { test } from '../../test-dom.test';
export default test({ export default test({
// this is the custom rendered component...it doesn't have anything inside because the part of the renderer // 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: '<custom></custom>', html: '<custom></custom>',
test({ assert, dom_elements }) { test({ assert, target }) {
// we then get the element out of dom_elements // we then get the element out of target.elements_children
const [div] = dom_elements; const [div] = target.children[0].elements_children;
// check that is an actual DOM element and that it has the expected content // check that is an actual DOM element and that it has the expected content
assert.instanceOf(div, HTMLDivElement); assert.instanceOf(div, HTMLDivElement);
assert.equal(div.outerHTML, '<div><span>hello from child</span></div>'); assert.equal(div.outerHTML, '<div><span>hello from child</span></div>');

@ -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'));
}
});

@ -0,0 +1,23 @@
<svelte:options customRenderer={null} />
<script>
import Child from './Child.svelte';
let visible = $state(true);
export function hide() {
visible = false;
}
export function show() {
visible = true;
}
</script>
<div>
{#if visible}
Cool: <Child />
{/if}
</div>
<div id="keep-me"></div>

@ -0,0 +1,7 @@
<script>
let { message } = $props();
</script>
<div>
<span>{message}</span>
</div>

@ -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);
}
});

@ -0,0 +1,20 @@
<svelte:options customRenderer={null} />
<script>
import Child from './Child.svelte';
let visible = $state(true);
export function hide() {
visible = false;
}
export function show() {
visible = true;
}
</script>
<div>
{#if visible}
test: <Child message="hello from child"></Child>
{/if}
</div>

@ -0,0 +1,7 @@
<script>
let { message } = $props();
</script>
<div>
<span>{message}</span>
</div>

@ -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);
}
});

@ -0,0 +1,20 @@
<svelte:options customRenderer={null} />
<script>
import Child from './Child.svelte';
let visible = $state(true);
export function hide() {
visible = false;
}
export function show() {
visible = true;
}
</script>
<div>
{#if visible}
<Child message="hello from child"></Child>
{/if}
</div>

@ -1,8 +1,8 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target }) { test({ assert, target, utils }) {
const elements = target.children.filter((/** @type {any} */ n) => n.type === 'element'); const elements = target.children.filter(utils.filter_elements());
assert.equal(elements.length, 4); assert.equal(elements.length, 4);

@ -1,7 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target, serialize }) { test({ assert, target, serialize, utils }) {
const html = serialize(target); const html = serialize(target);
assert.equal( assert.equal(
html, html,
@ -9,17 +9,13 @@ export default test({
); );
// Verify individual attribute access on the object node // Verify individual attribute access on the object node
const div = target.children.find( const div = target.children.find(utils.filter_elements((n) => n.name === 'div'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'div'
);
assert.ok(div); assert.ok(div);
assert.equal(div.attributes['class'], 'container'); assert.equal(div?.attributes['class'], 'container');
assert.equal(div.attributes['data-color'], 'red'); assert.equal(div?.attributes['data-color'], 'red');
const span = div.children.find( const span = div?.children.find(utils.filter_elements((n) => n.name === 'span'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'span'
);
assert.ok(span); assert.ok(span);
assert.equal(span.attributes['id'], 'label'); assert.equal(span?.attributes['id'], 'label');
} }
}); });

@ -2,24 +2,19 @@ import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target, dispatch_event }) { test({ assert, target, dispatch_event, utils }) {
const inputs = target.children.filter( const inputs = target.children.filter(utils.filter_elements((n) => n.name === 'input'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'input' const button = target.children.find(utils.filter_elements((n) => n.name === 'button'));
); const select = target.children.find(utils.filter_elements((n) => n.name === 'select'));
const button = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
const select = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'select'
);
const selected_option = select.children.find(
(/** @type {any} */ n) =>
n.type === 'element' && n.name === 'option' && n.attributes['value'] === 'other'
);
assert.equal(inputs.length, 4); assert.equal(inputs.length, 4);
assert.ok(button); assert.ok(button);
assert.ok(select); assert.ok(select);
const selected_option = select?.children.find(
utils.filter_elements((n) => n.name === 'option' && n.attributes['value'] === 'other')
);
assert.ok(selected_option); assert.ok(selected_option);
// Input 1: direct defaultValue attribute // Input 1: direct defaultValue attribute
@ -61,8 +56,8 @@ export default test({
'defaultChecked via spread should go through renderer.setAttribute, not element.defaultChecked' 'defaultChecked via spread should go through renderer.setAttribute, not element.defaultChecked'
); );
assert.equal(select.attributes['defaultValue'], 'default_val'); assert.equal(select?.attributes['defaultValue'], 'default_val');
assert.equal(selected_option.attributes['selected'], ''); assert.equal(selected_option?.attributes['selected'], '');
// --- After update --- // --- After update ---
dispatch_event(button, 'click'); dispatch_event(button, 'click');
@ -88,7 +83,7 @@ export default test({
'updated defaultChecked=false via spread should go through renderer.removeAttribute' 'updated defaultChecked=false via spread should go through renderer.removeAttribute'
); );
assert.equal(select.attributes['defaultValue'], 'new_default'); assert.equal(select?.attributes['defaultValue'], 'new_default');
assert.equal(selected_option.attributes['selected'], undefined); assert.equal(selected_option?.attributes['selected'], undefined);
} }
}); });

@ -2,19 +2,17 @@ import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target, serialize, logs }) { test({ assert, target, utils, logs }) {
const button = target.children.find( const button = target.children.find(utils.filter_elements((n) => n.name === 'button'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.ok(button); assert.ok(button);
const listeners = button.listeners?.click; const listeners = button?.listeners?.click;
assert.ok(listeners, 'button should have click listeners'); assert.ok(listeners, 'button should have click listeners');
// Call the handler with multiple arguments. // Call the handler with multiple arguments.
// Custom renderers may pass multiple arguments to event handlers, // Custom renderers may pass multiple arguments to event handlers,
// so we need to make sure all arguments are forwarded. // 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); handler.call(button, { type: 'click' }, 'extra', 42);
} }
flushSync(); flushSync();

@ -3,19 +3,17 @@ import { test } from '../../test';
export default test({ export default test({
html: '<button>click me</button> <p>0</p>', html: '<button>click me</button> <p>0</p>',
test({ assert, target, serialize, logs }) { test({ assert, target, serialize, logs, utils }) {
const button = target.children.find( const button = target.children.find(utils.filter_elements((n) => n.name === 'button'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.ok(button); assert.ok(button);
const listeners = button.listeners?.click; const listeners = button?.listeners?.click;
assert.ok(listeners, 'button should have click listeners'); assert.ok(listeners, 'button should have click listeners');
// Call the handler with multiple arguments. // Call the handler with multiple arguments.
// Custom renderers may pass multiple arguments to event handlers, // Custom renderers may pass multiple arguments to event handlers,
// so we need to make sure all arguments are forwarded through spreads too. // 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); handler.call(button, { type: 'click' }, 'extra', 42);
} }
flushSync(); flushSync();

@ -2,23 +2,19 @@ import { test } from '../../test';
export default test({ export default test({
html: '<select value="b"><option value="a">A</option><option value="b">B</option><option value="c">C</option></select> <p>b</p>', html: '<select value="b"><option value="a">A</option><option value="b">B</option><option value="c">C</option></select> <p>b</p>',
test({ assert, target, serialize }) { test({ assert, target, serialize, utils }) {
const select = target.children.find( const select = target.children.find(utils.filter_elements((n) => n.name === 'select'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'select'
);
assert.ok(select); assert.ok(select);
// The select element should have a value attribute set via the normal attribute path // 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 // Each option should have its value as a regular attribute
const options = select.children.filter( const options = select?.children.filter(utils.filter_elements((n) => n.name === 'option'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'option' assert.equal(options?.length, 3);
); assert.equal(options?.[0]?.attributes['value'], 'a');
assert.equal(options.length, 3); assert.equal(options?.[1]?.attributes['value'], 'b');
assert.equal(options[0].attributes['value'], 'a'); assert.equal(options?.[2]?.attributes['value'], 'c');
assert.equal(options[1].attributes['value'], 'b');
assert.equal(options[2].attributes['value'], 'c');
const html = serialize(target); const html = serialize(target);
assert.equal( assert.equal(

@ -2,14 +2,10 @@ import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target, dispatch_event }) { test({ assert, utils, target, dispatch_event }) {
// Find all inputs and the button // Find all inputs and the button
const inputs = target.children.filter( const inputs = target.children.filter(utils.filter_elements((n) => n.name === 'input'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'input' const button = target.children.find(utils.filter_elements((n) => n.name === 'button'));
);
const button = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.equal(inputs.length, 5); assert.equal(inputs.length, 5);
assert.ok(button); assert.ok(button);

@ -1,18 +1,16 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target }) { test({ assert, target, utils }) {
// If we got here, the component mounted without crashing on document.body access. // If we got here, the component mounted without crashing on document.body access.
// Verify autofocus is set as a regular attribute. // Verify autofocus is set as a regular attribute.
const input = target.children.find( const input = target.children.find(utils.filter_elements((n) => n.name === 'input'));
(/** @type {any} */ n) => n.type === 'element' && n.name === 'input'
);
assert.ok(input, 'input element should exist'); assert.ok(input, 'input element should exist');
assert.equal( assert.equal(
input.attributes['autofocus'], input?.attributes['autofocus'],
'true', 'true',
'autofocus should be set as a regular attribute' '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');
} }
}); });

@ -5,10 +5,18 @@ import { assert } from 'vitest';
import { compile_directory } from '../helpers.js'; import { compile_directory } from '../helpers.js';
import { suite_with_variants, type BaseTest } from '../suite.js'; import { suite_with_variants, type BaseTest } from '../suite.js';
import type { CompileOptions } from '#compiler'; import type { CompileOptions } from '#compiler';
import renderer, { create_root, serialize, dispatch_event, dom_elements } from './renderer.js'; import renderer, {
import { mount, unmount } from '../../src/index-client.js'; create_root,
serialize,
dispatch_event,
type ObjFragment,
type ObjElement,
type ObjNode
} from './renderer.js';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { globSync } from 'tinyglobby'; 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 // `_config.js` test callbacks rely on inferred parameter types, which
// TypeScript treats as non-explicit and rejects for chai's assertion-function // TypeScript treats as non-explicit and rejects for chai's assertion-function
@ -26,11 +34,13 @@ type NonAssertingMethods = {
type Assert = Omit<typeof import('vitest').assert, keyof NonAssertingMethods> & NonAssertingMethods; type Assert = Omit<typeof import('vitest').assert, keyof NonAssertingMethods> & NonAssertingMethods;
export interface CustomRendererTest extends BaseTest { interface CustomRendererHydrateTest extends BaseTest {
html?: string; html?: string;
compileOptions?: Partial<CompileOptions>; compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>; props?: Record<string, any>;
server_props?: Record<string, any>;
context?: Map<any, any>; context?: Map<any, any>;
hydrate: true;
error?: string; error?: string;
compile_error?: string; compile_error?: string;
compile_warnings?: false; compile_warnings?: false;
@ -38,7 +48,7 @@ export interface CustomRendererTest extends BaseTest {
warnings?: string[]; warnings?: string[];
test?: (args: { test?: (args: {
assert: Assert; assert: Assert;
target: any; target: HTMLElement;
component: Record<string, any>; component: Record<string, any>;
mod: any; mod: any;
logs: any[]; logs: any[];
@ -46,10 +56,48 @@ export interface CustomRendererTest extends BaseTest {
renderer: typeof renderer; renderer: typeof renderer;
serialize: typeof serialize; serialize: typeof serialize;
dispatch_event: typeof dispatch_event; dispatch_event: typeof dispatch_event;
dom_elements: Array<DocumentFragment | Node>;
}) => void | Promise<void>; }) => void | Promise<void>;
} }
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<CompileOptions>;
props?: Record<string, any>;
server_props?: Record<string, any>;
context?: Map<any, any>;
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<string, any>;
mod: any;
logs: any[];
warnings: any[];
renderer: typeof renderer;
serialize: typeof serialize;
dispatch_event: typeof dispatch_event;
}) => void | Promise<void>;
}
export type CustomRendererTest = CustomRendererHydrateTest | CustomRendererNonHydrateTest;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
const console_log = console.log; const console_log = console.log;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -97,6 +145,10 @@ async function common_setup(
try { try {
await compile_directory(cwd, 'client', compile_options); await compile_directory(cwd, 'client', compile_options);
if (config.hydrate) {
await compile_directory(cwd, 'server', compile_options);
}
} catch (err) { } catch (err) {
if (config.compile_error) { if (config.compile_error) {
assert.include((err as Error).message, 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 { try {
const mod = await import(`${cwd}/_output/client/main.svelte.js`); 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(); const target = create_root();
let component: Record<string, any> | undefined; let component: Record<string, any> | undefined;
try { try {
dom_elements.length = 0;
component = mount(mod.default, { component = mount(mod.default, {
renderer, renderer,
target, target,
@ -209,16 +266,16 @@ async function run_test(cwd: string, config: CustomRendererTest, compile_options
try { try {
if (config.test) { if (config.test) {
await config.test({ await config.test({
utils,
assert, assert,
target, target: target as never,
component: component ?? {}, component: component ?? {},
mod, mod,
logs, logs,
warnings, warnings,
renderer: renderer, renderer: renderer,
serialize, serialize,
dispatch_event, dispatch_event
dom_elements
}); });
} }
@ -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 { export function ok(value: any): asserts value {
if (!value) { if (!value) {
throw new Error(`Expected truthy value, got ${value}`); throw new Error(`Expected truthy value, got ${value}`);

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children?.()}

@ -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'
}
}
});

@ -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();
}

@ -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();
}

@ -0,0 +1,5 @@
<script>
import Component from "./Component.svelte";
</script>
<Component />

@ -2734,12 +2734,19 @@ declare module 'svelte/reactivity/window' {
} }
declare module 'svelte/renderer' { declare module 'svelte/renderer' {
export function createRenderer<T extends RendererNodes<object, object, object, object> = 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<TFragment, TElement, TTextNode, TComment> = Renderer<TFragment, TElement, TTextNode, TComment>>(renderer: R): R; export function createRenderer<T extends RendererNodes<object, object, object, object> = 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<any, any, any, any, any> | undefined = T extends DefaultNodes ? RendererNodes<any, any, any, any, any> : T["foreign"], R extends Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes> = Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes>>(renderer: R): R;
type Renderer< type Renderer<
TFragment extends object = object, TFragment extends object = object,
TElement extends object = object, TElement extends object = object,
TTextNode extends object = object, TTextNode extends object = object,
TComment extends object = object, TComment extends object = object,
TForeignNodes extends RendererNodes<any, any, any, any, any> | undefined = RendererNodes<
any,
any,
any,
any,
any
>,
TNode extends TFragment | TElement | TTextNode | TComment = TNode extends TFragment | TElement | TTextNode | TComment =
| TFragment | TFragment
| TElement | TElement
@ -2820,27 +2827,89 @@ declare module 'svelte/renderer' {
/** Remove an event listener of the given type and handler from the target node. */ /** Remove an event listener of the given type and handler from the target node. */
removeEventListener(target: TElement, type: string, handler: any, options?: any): void; 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<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment'],
element: TNode,
anchor:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['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<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment'],
anchor:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment']
| null
): void;
/** Remove a node that was inserted across renderer boundaries. */
removeForeign(
node:
| DefinedRendererNodes<TForeignNodes>['element']
| DefinedRendererNodes<TForeignNodes>['fragment']
| DefinedRendererNodes<TForeignNodes>['text']
| DefinedRendererNodes<TForeignNodes>['comment']
): void;
/** Remove a node that was inserted across renderer boundaries. */
removeFromForeign(node: TNode): void;
};
}; };
type DefinedRendererNodes<TNodes extends RendererNodes<any, any, any, any, any> | undefined> =
TNodes extends RendererNodes<any, any, any, any, any>
? TNodes
: RendererNodes<any, any, any, any, any>;
type RendererNodes< type RendererNodes<
Fragment extends object, Fragment extends object,
Element extends object, Element extends object,
TextNode extends object, TextNode extends object,
Comment extends object Comment extends object,
ForeignNode extends RendererNodes<any, any, any, any, any> = RendererNodes<
any,
any,
any,
any,
any
>
> = { > = {
fragment: Fragment; fragment: Fragment;
element: Element; element: Element;
text: TextNode; text: TextNode;
comment: Comment; comment: Comment;
foreign?: ForeignNode;
}; };
type NodeType = keyof RendererNodes<any, any, any, any>; type NodeType = Exclude<keyof RendererNodes<any, any, any, any, any>, 'foreign'>;
// to detect if the user is passing a type or not we create this type utils that adds a unique symbol // 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 // 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 // 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 // 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 UnsetObject = object & { readonly __unset: unique symbol };
type DefaultNodes = RendererNodes<UnsetObject, UnsetObject, UnsetObject, UnsetObject>; type DefaultNodes = RendererNodes<UnsetObject, UnsetObject, UnsetObject, UnsetObject>;

Loading…
Cancel
Save