pull/18405/merge
Paolo Ricciuti 3 days ago committed by GitHub
commit 9a2682aa5a
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
```
### renderer_missing_foreign
```
A custom renderer must define `foreign` to interleave with DOM or another custom renderer
```
### 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
## 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

@ -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) {

@ -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(

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

@ -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...

@ -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' ||

@ -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<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
* @returns {R}
*/

@ -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<any, any, any, any> | null}
*/
export let parent_renderer = null;
/**
* @param {Renderer<any, any, any, any> | null} value
*/
@ -14,11 +22,21 @@ export function set_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} [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);
}

@ -3,6 +3,13 @@ export type Renderer<
TElement extends object = object,
TTextNode 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 =
| 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<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<
Fragment extends object,
Element 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;
element: Element;
text: TextNode;
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
// 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<UnsetObject, UnsetObject, UnsetObject, UnsetObject>;

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

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

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

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

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

@ -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

@ -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) {

@ -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']>} */ (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;

@ -65,12 +65,21 @@ export interface Derived<V = unknown> extends Value<V>, 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 */

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

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

@ -8,11 +8,12 @@
import { createRenderer } from '../../src/renderer/index.js';
type ObjElement = {
export type ObjElement = {
type: 'element';
name: string;
attributes: Record<string, string>;
children: ObjNode[];
elements_children: Array<HTMLElement | DocumentFragment | Text | Comment>;
listeners: Record<string, Array<{ handler: any; options?: any }>>;
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<HTMLElement | DocumentFragment | Text | Comment>;
};
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<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<{
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);
}
}
});

@ -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({
// 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>',
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, '<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';
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);

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

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

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

@ -3,19 +3,17 @@ import { test } from '../../test';
export default test({
html: '<button>click me</button> <p>0</p>',
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();

@ -2,23 +2,19 @@ import { test } from '../../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>',
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(

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

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

@ -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<typeof import('vitest').assert, keyof NonAssertingMethods> & NonAssertingMethods;
export interface CustomRendererTest extends BaseTest {
interface CustomRendererHydrateTest extends BaseTest {
html?: string;
compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>;
server_props?: Record<string, any>;
context?: Map<any, any>;
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<string, any>;
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<DocumentFragment | Node>;
}) => 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
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<string, any> | 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}`);

@ -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 />

@ -2621,12 +2621,19 @@ declare module 'svelte/reactivity/window' {
}
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<
TFragment extends object = object,
TElement extends object = object,
TTextNode 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 =
| 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<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<
Fragment extends object,
Element 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;
element: Element;
text: TextNode;
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
// 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<UnsetObject, UnsetObject, UnsetObject, UnsetObject>;

Loading…
Cancel
Save