feat: skip static nodes (#12914)

* step one

* WIP

* more

* fix

* collapse sequential sibling calls

* working

* working but messy

* tidy up

* unused

* tweak

* tweak

* tidy

* tweak

* tweak

* revert

* changeset

* Update packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* revert this bit

* align

* comments

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
pull/12922/head
Rich Harris 1 month ago committed by GitHub
parent b2214d1c5b
commit 23bce2da20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: skip over static nodes in compiled client code

@ -1,6 +1,7 @@
/** @import { Expression } from 'estree' */
/** @import { ExpressionTag, SvelteNode, Text } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../../types' */
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import * as b from '../../../../../utils/builders.js';
import { build_template_literal, build_update } from './utils.js';
@ -9,42 +10,71 @@ import { build_template_literal, build_update } from './utils.js';
* (e.g. `{a} b {c}`) into a single update function. Along the way it creates
* corresponding template node references these updates are applied to.
* @param {SvelteNode[]} nodes
* @param {(is_text: boolean) => Expression} expression
* @param {(is_text: boolean) => Expression} initial
* @param {boolean} is_element
* @param {ComponentContext} context
*/
export function process_children(nodes, expression, is_element, { visit, state }) {
export function process_children(nodes, initial, is_element, { visit, state }) {
const within_bound_contenteditable = state.metadata.bound_contenteditable;
let prev = initial;
let skipped = 0;
/** @typedef {Array<Text | ExpressionTag>} Sequence */
/** @type {Sequence} */
let sequence = [];
/** @param {boolean} is_text */
function get_node(is_text) {
if (skipped === 0) {
return prev(is_text);
}
return b.call(
'$.sibling',
prev(false),
(is_text || skipped !== 1) && b.literal(skipped),
is_text && b.true
);
}
/**
* @param {boolean} is_text
* @param {string} name
*/
function flush_node(is_text, name) {
const expression = get_node(is_text);
let id = expression;
if (id.type !== 'Identifier') {
id = b.id(state.scope.generate(name));
state.init.push(b.var(id, expression));
}
prev = () => id;
skipped = 1; // the next node is `$.sibling(id)`
return id;
}
/**
* @param {Sequence} sequence
*/
function flush_sequence(sequence) {
if (sequence.length === 1) {
const node = sequence[0];
if (node.type === 'Text') {
let prev = expression;
expression = () => b.call('$.sibling', prev(false));
state.template.push(node.raw);
return;
}
if (sequence.length === 1 && sequence[0].type === 'Text') {
skipped += 1;
state.template.push(sequence[0].raw);
return;
}
// if this is a standalone `{expression}`, make sure we handle the case where
// no text node was created because the expression was empty during SSR
const needs_hydration_check = sequence.length === 1;
const id = get_node_id(expression(needs_hydration_check), state, 'text');
state.template.push(' ');
const { has_state, has_call, value } = build_template_literal(sequence, visit, state);
// if this is a standalone `{expression}`, make sure we handle the case where
// no text node was created because the expression was empty during SSR
const is_text = sequence.length === 1;
const id = flush_node(is_text, 'text');
const update = b.stmt(b.call('$.set_text', id, value));
if (has_call && !within_bound_contenteditable) {
@ -54,13 +84,9 @@ export function process_children(nodes, expression, is_element, { visit, state }
} else {
state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value)));
}
expression = (is_text) => b.call('$.sibling', id, is_text && b.true);
}
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
for (const node of nodes) {
if (node.type === 'Text' || node.type === 'ExpressionTag') {
sequence.push(node);
} else {
@ -69,60 +95,62 @@ export function process_children(nodes, expression, is_element, { visit, state }
sequence = [];
}
if (
node.type === 'SvelteHead' ||
node.type === 'TitleElement' ||
node.type === 'SnippetBlock'
) {
// These nodes do not contribute to the sibling/child tree
// TODO what about e.g. ConstTag and all the other things that
// get hoisted inside clean_nodes?
visit(node, state);
let child_state = state;
if (is_static_element(node)) {
skipped += 1;
} else if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true;
} else {
if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true;
visit(node, state);
} else {
const id = get_node_id(
expression(false),
state,
node.type === 'RegularElement' ? node.name : 'node'
);
expression = (is_text) => b.call('$.sibling', id, is_text && b.true);
visit(node, {
...state,
node: id
});
}
const id = flush_node(false, node.type === 'RegularElement' ? node.name : 'node');
child_state = { ...state, node: id };
}
visit(node, child_state);
}
}
if (sequence.length > 0) {
// if the final item in a fragment is static text,
// we need to force `hydrate_node` to advance
if (sequence.length === 1 && sequence[0].type === 'Text' && nodes.length > 1) {
state.init.push(b.stmt(b.call('$.next')));
}
flush_sequence(sequence);
}
// if there are trailing static text nodes/elements,
// traverse to the last (n - 1) one when hydrating
if (skipped > 1) {
skipped -= 1;
state.init.push(b.stmt(get_node(false)));
}
}
/**
* @param {Expression} expression
* @param {ComponentClientTransformState} state
* @param {string} name
*
* @param {SvelteNode} node
*/
function get_node_id(expression, state, name) {
let id = expression;
function is_static_element(node) {
if (node.type !== 'RegularElement') return false;
if (node.fragment.metadata.dynamic) return false;
if (id.type !== 'Identifier') {
id = b.id(state.scope.generate(name));
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') {
return false;
}
if (is_event_attribute(attribute)) {
return false;
}
if (attribute.value !== true && !is_text_attribute(attribute)) {
return false;
}
state.init.push(b.var(id, expression));
if (node.name === 'option' && attribute.name === 'value') {
return false;
}
if (node.name.includes('-')) {
return false; // we're setting all attributes on custom elements through properties
}
}
return id;
return true;
}

@ -70,6 +70,7 @@ export function create_text(value = '') {
* @param {N} node
* @returns {Node | null}
*/
/*@__NO_SIDE_EFFECTS__*/
export function get_first_child(node) {
return first_child_getter.call(node);
}
@ -79,6 +80,7 @@ export function get_first_child(node) {
* @param {N} node
* @returns {Node | null}
*/
/*@__NO_SIDE_EFFECTS__*/
export function get_next_sibling(node) {
return next_sibling_getter.call(node);
}
@ -137,17 +139,21 @@ export function first_child(fragment, is_text) {
/**
* Don't mark this as side-effect-free, hydration needs to walk all nodes
* @template {Node} N
* @param {N} node
* @param {TemplateNode} node
* @param {number} count
* @param {boolean} is_text
* @returns {Node | null}
*/
export function sibling(node, is_text = false) {
if (!hydrating) {
return /** @type {TemplateNode} */ (get_next_sibling(node));
export function sibling(node, count = 1, is_text = false) {
let next_sibling = hydrating ? hydrate_node : node;
while (count--) {
next_sibling = /** @type {TemplateNode} */ (get_next_sibling(next_sibling));
}
var next_sibling = /** @type {TemplateNode} */ (get_next_sibling(hydrate_node));
if (!hydrating) {
return next_sibling;
}
var type = next_sibling.nodeType;

@ -9,17 +9,17 @@ export default function Main($$anchor) {
let y = () => 'test';
var fragment = root();
var div = $.first_child(fragment);
var svg = $.sibling($.sibling(div));
var custom_element = $.sibling($.sibling(svg));
var div_1 = $.sibling($.sibling(custom_element));
var svg = $.sibling(div, 2);
var custom_element = $.sibling(svg, 2);
var div_1 = $.sibling(custom_element, 2);
$.template_effect(() => $.set_attribute(div_1, "foobar", y()));
var svg_1 = $.sibling($.sibling(div_1));
var svg_1 = $.sibling(div_1, 2);
$.template_effect(() => $.set_attribute(svg_1, "viewBox", y()));
var custom_element_1 = $.sibling($.sibling(svg_1));
var custom_element_1 = $.sibling(svg_1, 2);
$.template_effect(() => $.set_custom_element_data(custom_element_1, "fooBar", y()));

@ -13,11 +13,11 @@ export default function Purity($$anchor) {
p.textContent = Math.max(min, Math.min(max, number));
var p_1 = $.sibling($.sibling(p));
var p_1 = $.sibling(p, 2);
p_1.textContent = location.href;
var node = $.sibling($.sibling(p_1));
var node = $.sibling(p_1, 2);
Child(node, { prop: encodeURIComponent(value) });
$.append($$anchor, fragment);

@ -1,10 +1,20 @@
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";
var root = $.template(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header>`);
var root = $.template(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1> </h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> <!></main>`, 1);
export default function Skip_static_subtree($$anchor) {
var header = root();
export default function Skip_static_subtree($$anchor, $$props) {
var fragment = root();
var main = $.sibling($.first_child(fragment), 2);
var h1 = $.child(main);
var text = $.child(h1);
$.append($$anchor, header);
$.reset(h1);
var node = $.sibling(h1, 10);
$.html(node, () => $$props.content, false, false);
$.reset(main);
$.template_effect(() => $.set_text(text, $$props.title));
$.append($$anchor, fragment);
}

@ -1,5 +1,7 @@
import * as $ from "svelte/internal/server";
export default function Skip_static_subtree($$payload) {
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header>`;
export default function Skip_static_subtree($$payload, $$props) {
let { title, content } = $$props;
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)}</main>`;
}

@ -1,6 +1,21 @@
<script>
let { title, content } = $props();
</script>
<header>
<nav>
<a href="/">Home</a>
<a href="/away">Away</a>
</nav>
</header>
<main>
<h1>{title}</h1>
<div class="static">
<p>we don't need to traverse these nodes</p>
</div>
<p>or</p>
<p>these</p>
<p>ones</p>
{@html content}
</main>

@ -18,11 +18,11 @@ export default function State_proxy_literal($$anchor) {
$.remove_input_defaults(input);
var input_1 = $.sibling($.sibling(input));
var input_1 = $.sibling(input, 2);
$.remove_input_defaults(input_1);
var button = $.sibling($.sibling(input_1));
var button = $.sibling(input_1, 2);
button.__click = [reset, str, tpl];
$.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));

Loading…
Cancel
Save