Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: skip static nodes #12914

Merged
merged 21 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-trainers-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: skip over static nodes in compiled client code
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
* @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) {
Expand All @@ -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 {
Expand All @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why -= 1? Could use an explanatory comment (I suspect it's connected to the skipped = 1 thing above)

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;
}
18 changes: 12 additions & 6 deletions packages/svelte/src/internal/client/dom/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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>`;
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading