feat: accept `foreign` object in custom renderer to determine how to interleave custom renderers

fix-interleaving
paoloricciuti 3 weeks ago
parent 030c665a6f
commit a4f0ce17ce

@ -13,8 +13,9 @@ import { get_parent_node, remove_child } from '../dom/operations.js';
* @template {object} [TElement=T extends DefaultNodes ? object : T['element']]
* @template {object} [TTextNode=T extends DefaultNodes ? object : T['text']]
* @template {object} [TComment=T extends DefaultNodes ? object : T['comment']]
* @param {Renderer<TFragment, TElement, TTextNode, TComment>} renderer
* @returns {Renderer<TFragment, TElement, TTextNode, TComment> & { render: <Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map<any, any> } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map<any, any> }) => { component: Exports, unmount: () => void } }}
* @template {RendererNodes<any, any, any, any, any> | undefined} [TForeignNodes=T extends DefaultNodes ? RendererNodes<any, any, any, any, any> : T['foreign']]
* @param {Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes>} renderer
* @returns {Renderer<TFragment, TElement, TTextNode, TComment, TForeignNodes> & { render: <Props extends Record<string, any>, Exports extends Record<string, any>>(component: ComponentType<SvelteComponent<Props>> | Component<Props, Exports, any>, options: {} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map<any, any> } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map<any, any> }) => { component: Exports, unmount: () => void } }}
*/
export function createRenderer(renderer) {
const compound_renderer = {
@ -26,7 +27,7 @@ export function createRenderer(renderer) {
* @param {{} extends Props ? { target: TFragment | TElement | TTextNode | TComment, props?: Props, context?: Map<any, any> } : { target: TFragment | TElement | TTextNode | TComment, props: Props, context?: Map<any, any> }} options
*/
render(Component, { target, props, context }) {
var pop_renderer = push_renderer(compound_renderer);
var pop_renderer = push_renderer(compound_renderer, compound_renderer);
try {
/** @type {Exports} */

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

@ -20,7 +20,13 @@ import {
get_last_child
} from '../operations.js';
import { DEV } from 'esm-env';
import { push_renderer, current_renderer, parent_renderer } from '../../custom-renderer/state.js';
import {
push_renderer,
current_renderer,
parent_renderer,
set_parent_renderer,
set_renderer
} from '../../custom-renderer/state.js';
/**
* @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@ -96,6 +102,23 @@ export class BranchManager {
this.#parent_renderer = parent_renderer;
}
/**
* @param {() => void} fn
*/
#create_branch(fn) {
// we push current renderer twice because branches will always
// append to the current renderer
var pop_renderer = push_renderer(this.#renderer, this.#renderer);
try {
return branch(fn);
} finally {
pop_renderer?.();
// we restore the parent_renderer so that an append after a
// branch will append to the correct renderer
set_parent_renderer(this.#parent_renderer);
}
}
/**
* @param {Batch} batch
*/
@ -225,13 +248,13 @@ export class BranchManager {
append_child(fragment, target);
this.#offscreen.set(key, {
effect: branch(() => fn(target)),
effect: this.#create_branch(() => fn(target)),
fragment
});
} else {
this.#onscreen.set(
key,
branch(() => fn(this.anchor))
this.#create_branch(() => fn(this.anchor))
);
}
}

@ -50,7 +50,12 @@ import { current_batch } from '../../reactivity/batch.js';
import * as e from '../../errors.js';
import { tag } from '../../dev/tracing.js';
import { push_renderer, current_renderer, parent_renderer } from '../../custom-renderer/state.js';
import {
push_renderer,
current_renderer,
parent_renderer,
set_parent_renderer
} from '../../custom-renderer/state.js';
// When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
@ -216,12 +221,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0;
// Capture the renderer that was active when this each block was created.
// Needed so that the commit callback can push the correct renderer when doing
// DOM operations outside of an effect context (e.g. as a batch commit callback).
var renderer = current_renderer;
var parent = parent_renderer;
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
@ -230,6 +229,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
: /** @type {Text} */ (append_child(parent_node, create_text()));
}
// Branch contents always render within the same renderer as the template that created
// the block. The outer parent renderer is restored after the branch has run.
var renderer = current_renderer;
var parent = parent_renderer;
if (hydrating) {
hydrate_next();
}
@ -303,6 +307,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
state.pending.delete(batch);
}
// we push current renderer twice because branches will always
// append to the current renderer
var pop_renderer = push_renderer(renderer, renderer);
var effect = block(() => {
array = /** @type {V[]} */ (get(each_array));
var length = array.length;
@ -436,6 +444,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
// will now be `CLEAN`.
get(each_array);
});
pop_renderer?.();
// we restore the parent_renderer so that an append after a
// branch will append to the correct renderer
set_parent_renderer(parent);
/** @type {EachState} */
var state = { effect, flags, items, pending, outrogroups: null, fallback };

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

Loading…
Cancel
Save