feat: simpler effect DOM boundaries (#12258)

* simpler effect dom boundaries

* remove unused argument

* tidy up

* simplify

* skip redundant comment templates for components (and others TODO)

* same optimisation for render tags

* DRY out

* appease typescript

* changeset

* tighten up, leave note to self

* reinstate $.comment optimisation

* add explanation

* comments
pull/12264/head
Rich Harris 6 months ago committed by GitHub
parent dcc7ed4cc7
commit fbb7da7e35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: simpler effect DOM boundaries

@ -36,7 +36,6 @@ import {
EACH_KEYED,
is_capture_event,
TEMPLATE_FRAGMENT,
TEMPLATE_UNSET_START,
TEMPLATE_USE_IMPORT_NODE,
TRANSITION_GLOBAL,
TRANSITION_IN,
@ -1561,7 +1560,7 @@ export const template_visitors = {
const namespace = infer_namespace(context.state.metadata.namespace, parent, node.nodes);
const { hoisted, trimmed } = clean_nodes(
const { hoisted, trimmed, is_standalone } = clean_nodes(
parent,
node.nodes,
context.path,
@ -1675,6 +1674,10 @@ export const template_visitors = {
...state.init
);
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else {
if (is_standalone) {
// no need to create a template, we can just use the existing block's anchor
process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state });
} else {
/** @type {(is_text: boolean) => import('estree').Expression} */
const expression = (is_text) =>
@ -1682,39 +1685,16 @@ export const template_visitors = {
process_children(trimmed, expression, false, { ...context, state });
var first = trimmed[0];
/**
* If the first item in an effect is a static slot or render tag, it will clone
* a template but without creating a child effect. In these cases, we need to keep
* the current `effect.nodes.start` undefined, so that it can be populated by
* the item in question
* TODO come up with a better name than `unset`
*/
var unset = false;
if (first.type === 'SlotElement') unset = true;
if (first.type === 'RenderTag' && !first.metadata.dynamic) unset = true;
if (first.type === 'Component' && !first.metadata.dynamic && !context.state.options.hmr) {
unset = true;
}
const use_comment_template = state.template.length === 1 && state.template[0] === '<!>';
if (use_comment_template) {
// special case — we can use `$.comment` instead of creating a unique template
body.push(b.var(id, b.call('$.comment', unset && b.literal(unset))));
} else {
let flags = TEMPLATE_FRAGMENT;
if (unset) {
flags |= TEMPLATE_UNSET_START;
}
if (state.metadata.context.template_needs_import_node) {
flags |= TEMPLATE_USE_IMPORT_NODE;
}
if (state.template.length === 1 && state.template[0] === '<!>') {
// special case — we can use `$.comment` instead of creating a unique template
body.push(b.var(id, b.call('$.comment')));
} else {
add_template(template_name, [
b.template([b.quasi(state.template.join(''), true)], []),
b.literal(flags)
@ -1723,10 +1703,11 @@ export const template_visitors = {
body.push(b.var(id, b.call(template_name)));
}
body.push(...state.before_init, ...state.init);
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
}
body.push(...state.before_init, ...state.init);
}
} else {
body.push(...state.before_init, ...state.init);
}

@ -1002,6 +1002,8 @@ function serialize_inline_component(node, expression, context) {
)
);
context.state.template.push(statement);
} else if (context.state.skip_hydration_boundaries) {
context.state.template.push(statement);
} else {
context.state.template.push(block_open, statement, block_close);
@ -1112,7 +1114,7 @@ const template_visitors = {
const parent = context.path.at(-1) ?? node;
const namespace = infer_namespace(context.state.namespace, parent, node.nodes);
const { hoisted, trimmed } = clean_nodes(
const { hoisted, trimmed, is_standalone } = clean_nodes(
parent,
node.nodes,
context.path,
@ -1127,7 +1129,8 @@ const template_visitors = {
...context.state,
init: [],
template: [],
namespace
namespace,
skip_hydration_boundaries: is_standalone
};
for (const node of hoisted) {
@ -1180,17 +1183,23 @@ const template_visitors = {
return /** @type {import('estree').Expression} */ (context.visit(arg));
});
if (!context.state.skip_hydration_boundaries) {
context.state.template.push(block_open);
}
context.state.template.push(
block_open,
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
b.id('$$payload'),
...snippet_args
)
),
block_close
)
);
if (!context.state.skip_hydration_boundaries) {
context.state.template.push(block_close);
}
},
ClassDirective() {
throw new Error('Node should have been handled elsewhere');
@ -1925,7 +1934,8 @@ export function server_component(analysis, options) {
template: /** @type {any} */ (null),
namespace: options.namespace,
preserve_whitespace: options.preserveWhitespace,
private_derived: new Map()
private_derived: new Map(),
skip_hydration_boundaries: false
};
const module = /** @type {import('estree').Program} */ (

@ -22,6 +22,7 @@ export interface ComponentServerTransformState extends ServerTransformState {
readonly template: Array<Statement | Expression>;
readonly namespace: Namespace;
readonly preserve_whitespace: boolean;
readonly skip_hydration_boundaries: boolean;
}
export type Context = import('zimmerframe').Context<SvelteNode, ServerTransformState>;

@ -185,13 +185,18 @@ export function clean_nodes(
}
}
if (preserve_whitespace) {
return { hoisted, trimmed: regular };
}
let trimmed = regular;
if (!preserve_whitespace) {
trimmed = [];
let first, last;
while ((first = regular[0]) && first.type === 'Text' && !regex_not_whitespace.test(first.data)) {
while (
(first = regular[0]) &&
first.type === 'Text' &&
!regex_not_whitespace.test(first.data)
) {
regular.shift();
}
@ -200,7 +205,11 @@ export function clean_nodes(
first.data = first.data.replace(regex_starts_with_whitespaces, '');
}
while ((last = regular.at(-1)) && last.type === 'Text' && !regex_not_whitespace.test(last.data)) {
while (
(last = regular.at(-1)) &&
last.type === 'Text' &&
!regex_not_whitespace.test(last.data)
) {
regular.pop();
}
@ -224,9 +233,6 @@ export function clean_nodes(
parent.name === 'colgroup' ||
parent.name === 'datalist'));
/** @type {Compiler.SvelteNode[]} */
const trimmed = [];
// Replace any whitespace between a text and non-text node with a single spaceand keep whitespace
// as-is within text nodes, or between text nodes and expression tags (because in the end they count
// as one text). This way whitespace is mostly preserved when using CSS with `white-space: pre-line`
@ -260,8 +266,24 @@ export function clean_nodes(
trimmed.push(node);
}
}
}
var first = trimmed[0];
return { hoisted, trimmed };
/**
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
* comments we can just use the parent block's anchor for the component.
* TODO extend this optimisation to other cases
*/
const is_standalone =
trimmed.length === 1 &&
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
(first.type === 'Component' &&
!first.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
)));
return { hoisted, trimmed, is_standalone };
}
/**

@ -18,7 +18,6 @@ export const TRANSITION_GLOBAL = 1 << 2;
export const TEMPLATE_FRAGMENT = 1;
export const TEMPLATE_USE_IMPORT_NODE = 1 << 1;
export const TEMPLATE_UNSET_START = 1 << 2;
export const HYDRATION_START = '[';
export const HYDRATION_END = ']';

@ -1,4 +1,5 @@
/** @import { Source, Effect } from '#client' */
import { empty } from '../dom/operations.js';
import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set_should_intro } from '../render.js';
import { get } from '../runtime.js';
@ -19,7 +20,7 @@ export function hmr(source) {
/** @type {Effect} */
let effect;
block(anchor, 0, () => {
block(() => {
const component = get(source);
if (effect) {

@ -105,7 +105,7 @@ export function await_block(anchor, get_input, pending_fn, then_fn, catch_fn) {
}
}
var effect = block(anchor, 0, () => {
var effect = block(() => {
if (input === (input = get_input())) return;
if (is_promise(input)) {

@ -24,8 +24,7 @@ import {
run_out_transitions,
pause_children,
pause_effect,
resume_effect,
get_first_node
resume_effect
} from '../../reactivity/effects.js';
import { source, mutable_source, set } from '../../reactivity/sources.js';
import { is_array, is_frozen } from '../../utils.js';
@ -124,7 +123,7 @@ export function each(anchor, flags, get_collection, get_key, render_fn, fallback
/** @type {import('#client').Effect | null} */
var fallback = null;
block(anchor, 0, () => {
block(() => {
var collection = get_collection();
var array = is_array(collection)
@ -294,7 +293,9 @@ function reconcile(array, state, anchor, render_fn, flags, get_key) {
item = items.get(key);
if (item === undefined) {
var child_anchor = current ? get_first_node(current.e) : anchor;
var child_anchor = current
? /** @type {import('#client').EffectNodes} */ (current.e.nodes).start
: anchor;
prev = create_item(
child_anchor,
@ -515,10 +516,12 @@ function create_item(anchor, state, prev, next, value, key, index, render_fn, fl
* @param {Text | Element | Comment} anchor
*/
function move(item, next, anchor) {
var end = item.next ? get_first_node(item.next.e) : anchor;
var dest = next ? get_first_node(next.e) : anchor;
var end = item.next
? /** @type {import('#client').EffectNodes} */ (item.next.e.nodes).start
: anchor;
var node = get_first_node(item.e);
var dest = next ? /** @type {import('#client').EffectNodes} */ (next.e.nodes).start : anchor;
var node = /** @type {import('#client').EffectNodes} */ (item.e.nodes).start;
while (node !== end) {
var next_node = /** @type {import('#client').TemplateNode} */ (node.nextSibling);

@ -16,7 +16,7 @@ export function html(anchor, get_value, svg, mathml) {
/** @type {import('#client').Effect | null} */
var effect;
block(anchor, 0, () => {
block(() => {
if (value === (value = get_value())) return;
if (effect) {

@ -30,7 +30,7 @@ export function if_block(
var flags = elseif ? EFFECT_TRANSPARENT : 0;
block(anchor, flags, () => {
block(() => {
if (condition === (condition = !!get_condition())) return;
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
@ -78,5 +78,5 @@ export function if_block(
// continue in hydration mode
set_hydrating(true);
}
});
}, flags);
}

@ -17,7 +17,7 @@ export function key_block(anchor, get_key, render_fn) {
/** @type {Effect} */
let effect;
block(anchor, 0, () => {
block(() => {
if (safe_not_equal(key, (key = get_key()))) {
if (effect) {
pause_effect(effect);

@ -20,7 +20,7 @@ export function snippet(anchor, get_snippet, ...args) {
/** @type {import('#client').Effect | null} */
var snippet_effect;
block(anchor, EFFECT_TRANSPARENT, () => {
block(() => {
if (snippet === (snippet = get_snippet())) return;
if (snippet_effect) {
@ -31,7 +31,7 @@ export function snippet(anchor, get_snippet, ...args) {
if (snippet) {
snippet_effect = branch(() => /** @type {SnippetFn} */ (snippet)(anchor, ...args));
}
});
}, EFFECT_TRANSPARENT);
}
/**

@ -1,7 +1,5 @@
/** @import { TemplateNode, Dom, Effect } from '#client' */
import { DEV } from 'esm-env';
import { block, branch, pause_effect } from '../../reactivity/effects.js';
import { empty } from '../operations.js';
/**
* @template P
@ -18,12 +16,7 @@ export function component(anchor, get_component, render_fn) {
/** @type {Effect | null} */
let effect;
var component_anchor = anchor;
// create a dummy anchor for the HMR wrapper, if such there be
if (DEV) component_anchor = empty();
block(anchor, 0, () => {
block(() => {
if (component === (component = get_component())) return;
if (effect) {
@ -32,8 +25,7 @@ export function component(anchor, get_component, render_fn) {
}
if (component) {
if (DEV) anchor.before(component_anchor);
effect = branch(() => render_fn(component_anchor, component));
effect = branch(() => render_fn(anchor, component));
}
});
}

@ -48,7 +48,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
*/
let each_item_block = current_each_item;
block(anchor, 0, () => {
block(() => {
const next_tag = get_tag() || null;
const ns = get_namespace
? get_namespace()

@ -48,7 +48,7 @@ export function head(render_fn) {
}
try {
block(null, HEAD_EFFECT, () => render_fn(anchor));
block(() => render_fn(anchor), HEAD_EFFECT);
} finally {
if (was_hydrating) {
set_hydrate_nodes(/** @type {import('#client').TemplateNode[]} */ (previous_hydrate_nodes));

@ -78,7 +78,12 @@ export function child(node) {
export function first_child(fragment, is_text) {
if (!hydrating) {
// when not hydrating, `fragment` is a `DocumentFragment` (the result of calling `open_frag`)
return /** @type {DocumentFragment} */ (fragment).firstChild;
var first = /** @type {DocumentFragment} */ (fragment).firstChild;
// TODO prevent user comments with the empty string when preserveComments is true
if (first instanceof Comment && first.data === '') return first.nextSibling;
return first;
}
// if an {expression} is empty during SSR, there might be no

@ -2,27 +2,15 @@ import { get_start, hydrate_nodes, hydrate_start, hydrating } from './hydration.
import { empty } from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { current_effect } from '../runtime.js';
import {
TEMPLATE_FRAGMENT,
TEMPLATE_UNSET_START,
TEMPLATE_USE_IMPORT_NODE
} from '../../../constants.js';
import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js';
import { queue_micro_task } from './task.js';
/**
*
* @param {import('#client').TemplateNode | undefined | null} start
* @param {import('#client').TemplateNode} start
* @param {import('#client').TemplateNode} end
* @param {import('#client').TemplateNode | null} anchor
*/
export function assign_nodes(start, end, anchor = null) {
const effect = /** @type {import('#client').Effect} */ (current_effect);
if (effect.nodes === null) {
effect.nodes = { start, anchor, end };
} else if (effect.nodes.start === undefined) {
effect.nodes.start = start;
}
export function assign_nodes(start, end) {
/** @type {import('#client').Effect} */ (current_effect).nodes ??= { start, end };
}
/**
@ -38,8 +26,11 @@ export function template(content, flags) {
/** @type {Node} */
var node;
/**
* Whether or not the first item is a text/element node. If not, we need to
* create an additional comment node to act as `effect.nodes.start`
*/
var has_start = !content.startsWith('<!>');
var unset = (flags & TEMPLATE_UNSET_START) !== 0;
return () => {
if (hydrating) {
@ -49,7 +40,7 @@ export function template(content, flags) {
}
if (!node) {
node = create_fragment_from_html(content);
node = create_fragment_from_html(has_start ? content : '<!>' + content);
if (!is_fragment) node = /** @type {Node} */ (node.firstChild);
}
@ -58,11 +49,10 @@ export function template(content, flags) {
);
if (is_fragment) {
var first = /** @type {import('#client').TemplateNode} */ (clone.firstChild);
var start = has_start ? first : unset ? undefined : null;
var start = /** @type {import('#client').TemplateNode} */ (clone.firstChild);
var end = /** @type {import('#client').TemplateNode} */ (clone.lastChild);
assign_nodes(start, end, first);
assign_nodes(start, end);
} else {
assign_nodes(clone, clone);
}
@ -103,15 +93,18 @@ export function template_with_script(content, flags) {
*/
/*#__NO_SIDE_EFFECTS__*/
export function ns_template(content, flags, ns = 'svg') {
/**
* Whether or not the first item is a text/element node. If not, we need to
* create an additional comment node to act as `effect.nodes.start`
*/
var has_start = !content.startsWith('<!>');
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var wrapped = `<${ns}>${content}</${ns}>`;
var wrapped = `<${ns}>${has_start ? content : '<!>' + content}</${ns}>`;
/** @type {Element | DocumentFragment} */
var node;
var has_start = !content.startsWith('<!>');
var unset = (flags & TEMPLATE_UNSET_START) !== 0;
return () => {
if (hydrating) {
assign_nodes(get_start(), hydrate_nodes[hydrate_nodes.length - 1]);
@ -136,11 +129,10 @@ export function ns_template(content, flags, ns = 'svg') {
var clone = /** @type {import('#client').TemplateNode} */ (node.cloneNode(true));
if (is_fragment) {
var first = /** @type {import('#client').TemplateNode} */ (clone.firstChild);
var start = has_start ? first : unset ? undefined : null;
var start = /** @type {import('#client').TemplateNode} */ (clone.firstChild);
var end = /** @type {import('#client').TemplateNode} */ (clone.lastChild);
assign_nodes(start, end, first);
assign_nodes(start, end);
} else {
assign_nodes(clone, clone);
}
@ -238,10 +230,7 @@ export function text(anchor) {
return node;
}
/**
* @param {boolean} unset
*/
export function comment(unset = false) {
export function comment() {
// we're not delegating to `template` here for performance reasons
if (hydrating) {
assign_nodes(get_start(), hydrate_nodes[hydrate_nodes.length - 1]);
@ -250,10 +239,11 @@ export function comment(unset = false) {
}
var frag = document.createDocumentFragment();
var start = document.createComment('');
var anchor = empty();
frag.append(anchor);
frag.append(start, anchor);
assign_nodes(unset ? undefined : null, anchor, anchor);
assign_nodes(start, anchor);
return frag;
}

@ -299,14 +299,11 @@ export function template_effect(fn) {
}
/**
* @param {import('#client').TemplateNode | null} anchor
* @param {number} flags
* @param {(() => void)} fn
* @param {number} flags
*/
export function block(anchor, flags, fn) {
const effect = create_effect(RENDER_EFFECT | BLOCK_EFFECT | flags, fn, true);
if (anchor !== null) effect.nodes = { start: null, anchor: null, end: anchor };
return effect;
export function block(fn, flags = 0) {
return create_effect(RENDER_EFFECT | BLOCK_EFFECT | flags, fn, true);
}
/**
@ -346,7 +343,7 @@ export function destroy_effect(effect, remove_dom = true) {
if ((remove_dom || (effect.f & HEAD_EFFECT) !== 0) && effect.nodes !== null) {
/** @type {import('#client').TemplateNode | null} */
var node = get_first_node(effect);
var node = effect.nodes.start;
var end = effect.nodes.end;
while (node !== null) {
@ -392,37 +389,6 @@ export function destroy_effect(effect, remove_dom = true) {
null;
}
/**
* @param {import('#client').Effect} effect
* @returns {import('#client').TemplateNode}
*/
export function get_first_node(effect) {
var nodes = /** @type {NonNullable<typeof effect.nodes>} */ (effect.nodes);
var start = nodes.start;
if (start === undefined) {
// edge case — a snippet or component was the first item inside the effect,
// but it didn't render any DOM. in this case, we return the item's anchor
return /** @type {import('#client').TemplateNode} */ (nodes.anchor);
}
if (start !== null) {
return start;
}
var child = effect.first;
while (child && (child.nodes === null || (child.f & HEAD_EFFECT) !== 0)) {
child = child.next;
}
if (child !== null && child.nodes !== null) {
return get_first_node(child);
}
// in the case that there's no DOM, return the first anchor
return nodes.end;
}
/**
* Detach an effect from the effect tree, freeing up memory and
* reducing the amount of work that happens on subsequent traversals

@ -34,13 +34,20 @@ export interface Derived<V = unknown> extends Value<V>, Reaction {
deriveds: null | Derived[];
}
export interface EffectNodes {
start: TemplateNode;
end: TemplateNode;
}
export interface Effect extends Reaction {
parent: Effect | null;
nodes: null | {
start: undefined | null | TemplateNode;
anchor: null | TemplateNode;
end: TemplateNode;
};
/**
* Branch effects store their start/end nodes so that they can be
* removed when the effect is destroyed, or moved when an `each`
* block is reconciled. In the case of a single text/element node,
* `start` and `end` will be the same.
*/
nodes: null | EffectNodes;
/** The associated component context */
ctx: null | ComponentContext;
/** The effect function */

@ -1,7 +1,7 @@
import { DEV } from 'esm-env';
import { clear_text_content, create_element, empty, init_operations } from './dom/operations.js';
import { HYDRATION_ERROR, HYDRATION_START, PassiveDelegatedEvents } from '../../constants.js';
import { flush_sync, push, pop, current_component_context } from './runtime.js';
import { flush_sync, push, pop, current_component_context, current_effect } from './runtime.js';
import { effect_root, branch } from './reactivity/effects.js';
import {
hydrate_anchor,

@ -3,7 +3,7 @@ import * as $ from "svelte/internal/client";
import TextInput from './Child.svelte';
var root_1 = $.template(`Something`, 1);
var root = $.template(`<!> `, 5);
var root = $.template(`<!> `, 1);
export default function Bind_component_snippet($$anchor) {
const snippet = ($$anchor) => {

@ -2,9 +2,5 @@ import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";
export default function Bind_this($$anchor) {
var fragment = $.comment(true);
var node = $.first_child(fragment);
$.bind_this(Foo(node, { $$legacy: true }), ($$value) => foo = $$value, () => foo);
$.append($$anchor, fragment);
$.bind_this(Foo($$anchor, { $$legacy: true }), ($$value) => foo = $$value, () => foo);
}

@ -1,7 +1,5 @@
import * as $ from "svelte/internal/server";
export default function Bind_this($$payload) {
$$payload.out += `<!--[-->`;
Foo($$payload, {});
$$payload.out += `<!--]-->`;
}

@ -9,10 +9,8 @@ export default function Function_prop_no_getter($$anchor) {
}
const plusOne = (num) => num + 1;
var fragment = $.comment(true);
var node = $.first_child(fragment);
Button(node, {
Button($$anchor, {
onmousedown: () => $.set(count, $.get(count) + 1),
onmouseup,
onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)))),
@ -24,6 +22,4 @@ export default function Function_prop_no_getter($$anchor) {
},
$$slots: { default: true }
});
$.append($$anchor, fragment);
}

@ -9,8 +9,6 @@ export default function Function_prop_no_getter($$payload) {
const plusOne = (num) => num + 1;
$$payload.out += `<!--[-->`;
Button($$payload, {
onmousedown: () => count += 1,
onmouseup,
@ -20,6 +18,4 @@ export default function Function_prop_no_getter($$payload) {
},
$$slots: { default: true }
});
$$payload.out += `<!--]-->`;
}
Loading…
Cancel
Save