Skip to content

Commit 23bce2d

Browse files
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 <[email protected]> * revert this bit * align * comments --------- Co-authored-by: Simon H <[email protected]>
1 parent b2214d1 commit 23bce2d

File tree

9 files changed

+150
-84
lines changed

9 files changed

+150
-84
lines changed

.changeset/ten-trainers-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: skip over static nodes in compiled client code
Lines changed: 91 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @import { Expression } from 'estree' */
22
/** @import { ExpressionTag, SvelteNode, Text } from '#compiler' */
33
/** @import { ComponentClientTransformState, ComponentContext } from '../../types' */
4+
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
45
import * as b from '../../../../../utils/builders.js';
56
import { build_template_literal, build_update } from './utils.js';
67

@@ -9,42 +10,71 @@ import { build_template_literal, build_update } from './utils.js';
910
* (e.g. `{a} b {c}`) into a single update function. Along the way it creates
1011
* corresponding template node references these updates are applied to.
1112
* @param {SvelteNode[]} nodes
12-
* @param {(is_text: boolean) => Expression} expression
13+
* @param {(is_text: boolean) => Expression} initial
1314
* @param {boolean} is_element
1415
* @param {ComponentContext} context
1516
*/
16-
export function process_children(nodes, expression, is_element, { visit, state }) {
17+
export function process_children(nodes, initial, is_element, { visit, state }) {
1718
const within_bound_contenteditable = state.metadata.bound_contenteditable;
19+
let prev = initial;
20+
let skipped = 0;
1821

1922
/** @typedef {Array<Text | ExpressionTag>} Sequence */
20-
2123
/** @type {Sequence} */
2224
let sequence = [];
2325

26+
/** @param {boolean} is_text */
27+
function get_node(is_text) {
28+
if (skipped === 0) {
29+
return prev(is_text);
30+
}
31+
32+
return b.call(
33+
'$.sibling',
34+
prev(false),
35+
(is_text || skipped !== 1) && b.literal(skipped),
36+
is_text && b.true
37+
);
38+
}
39+
40+
/**
41+
* @param {boolean} is_text
42+
* @param {string} name
43+
*/
44+
function flush_node(is_text, name) {
45+
const expression = get_node(is_text);
46+
let id = expression;
47+
48+
if (id.type !== 'Identifier') {
49+
id = b.id(state.scope.generate(name));
50+
state.init.push(b.var(id, expression));
51+
}
52+
53+
prev = () => id;
54+
skipped = 1; // the next node is `$.sibling(id)`
55+
56+
return id;
57+
}
58+
2459
/**
2560
* @param {Sequence} sequence
2661
*/
2762
function flush_sequence(sequence) {
28-
if (sequence.length === 1) {
29-
const node = sequence[0];
30-
31-
if (node.type === 'Text') {
32-
let prev = expression;
33-
expression = () => b.call('$.sibling', prev(false));
34-
state.template.push(node.raw);
35-
return;
36-
}
63+
if (sequence.length === 1 && sequence[0].type === 'Text') {
64+
skipped += 1;
65+
state.template.push(sequence[0].raw);
66+
return;
3767
}
3868

39-
// if this is a standalone `{expression}`, make sure we handle the case where
40-
// no text node was created because the expression was empty during SSR
41-
const needs_hydration_check = sequence.length === 1;
42-
const id = get_node_id(expression(needs_hydration_check), state, 'text');
43-
4469
state.template.push(' ');
4570

4671
const { has_state, has_call, value } = build_template_literal(sequence, visit, state);
4772

73+
// if this is a standalone `{expression}`, make sure we handle the case where
74+
// no text node was created because the expression was empty during SSR
75+
const is_text = sequence.length === 1;
76+
const id = flush_node(is_text, 'text');
77+
4878
const update = b.stmt(b.call('$.set_text', id, value));
4979

5080
if (has_call && !within_bound_contenteditable) {
@@ -54,13 +84,9 @@ export function process_children(nodes, expression, is_element, { visit, state }
5484
} else {
5585
state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value)));
5686
}
57-
58-
expression = (is_text) => b.call('$.sibling', id, is_text && b.true);
5987
}
6088

61-
for (let i = 0; i < nodes.length; i += 1) {
62-
const node = nodes[i];
63-
89+
for (const node of nodes) {
6490
if (node.type === 'Text' || node.type === 'ExpressionTag') {
6591
sequence.push(node);
6692
} else {
@@ -69,60 +95,62 @@ export function process_children(nodes, expression, is_element, { visit, state }
6995
sequence = [];
7096
}
7197

72-
if (
73-
node.type === 'SvelteHead' ||
74-
node.type === 'TitleElement' ||
75-
node.type === 'SnippetBlock'
76-
) {
77-
// These nodes do not contribute to the sibling/child tree
78-
// TODO what about e.g. ConstTag and all the other things that
79-
// get hoisted inside clean_nodes?
80-
visit(node, state);
98+
let child_state = state;
99+
100+
if (is_static_element(node)) {
101+
skipped += 1;
102+
} else if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
103+
node.metadata.is_controlled = true;
81104
} else {
82-
if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
83-
node.metadata.is_controlled = true;
84-
visit(node, state);
85-
} else {
86-
const id = get_node_id(
87-
expression(false),
88-
state,
89-
node.type === 'RegularElement' ? node.name : 'node'
90-
);
91-
92-
expression = (is_text) => b.call('$.sibling', id, is_text && b.true);
93-
94-
visit(node, {
95-
...state,
96-
node: id
97-
});
98-
}
105+
const id = flush_node(false, node.type === 'RegularElement' ? node.name : 'node');
106+
child_state = { ...state, node: id };
99107
}
108+
109+
visit(node, child_state);
100110
}
101111
}
102112

103113
if (sequence.length > 0) {
104-
// if the final item in a fragment is static text,
105-
// we need to force `hydrate_node` to advance
106-
if (sequence.length === 1 && sequence[0].type === 'Text' && nodes.length > 1) {
107-
state.init.push(b.stmt(b.call('$.next')));
108-
}
109-
110114
flush_sequence(sequence);
111115
}
116+
117+
// if there are trailing static text nodes/elements,
118+
// traverse to the last (n - 1) one when hydrating
119+
if (skipped > 1) {
120+
skipped -= 1;
121+
state.init.push(b.stmt(get_node(false)));
122+
}
112123
}
113124

114125
/**
115-
* @param {Expression} expression
116-
* @param {ComponentClientTransformState} state
117-
* @param {string} name
126+
*
127+
* @param {SvelteNode} node
118128
*/
119-
function get_node_id(expression, state, name) {
120-
let id = expression;
129+
function is_static_element(node) {
130+
if (node.type !== 'RegularElement') return false;
131+
if (node.fragment.metadata.dynamic) return false;
121132

122-
if (id.type !== 'Identifier') {
123-
id = b.id(state.scope.generate(name));
133+
for (const attribute of node.attributes) {
134+
if (attribute.type !== 'Attribute') {
135+
return false;
136+
}
137+
138+
if (is_event_attribute(attribute)) {
139+
return false;
140+
}
141+
142+
if (attribute.value !== true && !is_text_attribute(attribute)) {
143+
return false;
144+
}
124145

125-
state.init.push(b.var(id, expression));
146+
if (node.name === 'option' && attribute.name === 'value') {
147+
return false;
148+
}
149+
150+
if (node.name.includes('-')) {
151+
return false; // we're setting all attributes on custom elements through properties
152+
}
126153
}
127-
return id;
154+
155+
return true;
128156
}

packages/svelte/src/internal/client/dom/operations.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export function create_text(value = '') {
7070
* @param {N} node
7171
* @returns {Node | null}
7272
*/
73+
/*@__NO_SIDE_EFFECTS__*/
7374
export function get_first_child(node) {
7475
return first_child_getter.call(node);
7576
}
@@ -79,6 +80,7 @@ export function get_first_child(node) {
7980
* @param {N} node
8081
* @returns {Node | null}
8182
*/
83+
/*@__NO_SIDE_EFFECTS__*/
8284
export function get_next_sibling(node) {
8385
return next_sibling_getter.call(node);
8486
}
@@ -137,17 +139,21 @@ export function first_child(fragment, is_text) {
137139

138140
/**
139141
* Don't mark this as side-effect-free, hydration needs to walk all nodes
140-
* @template {Node} N
141-
* @param {N} node
142+
* @param {TemplateNode} node
143+
* @param {number} count
142144
* @param {boolean} is_text
143145
* @returns {Node | null}
144146
*/
145-
export function sibling(node, is_text = false) {
146-
if (!hydrating) {
147-
return /** @type {TemplateNode} */ (get_next_sibling(node));
147+
export function sibling(node, count = 1, is_text = false) {
148+
let next_sibling = hydrating ? hydrate_node : node;
149+
150+
while (count--) {
151+
next_sibling = /** @type {TemplateNode} */ (get_next_sibling(next_sibling));
148152
}
149153

150-
var next_sibling = /** @type {TemplateNode} */ (get_next_sibling(hydrate_node));
154+
if (!hydrating) {
155+
return next_sibling;
156+
}
151157

152158
var type = next_sibling.nodeType;
153159

packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@ export default function Main($$anchor) {
99
let y = () => 'test';
1010
var fragment = root();
1111
var div = $.first_child(fragment);
12-
var svg = $.sibling($.sibling(div));
13-
var custom_element = $.sibling($.sibling(svg));
14-
var div_1 = $.sibling($.sibling(custom_element));
12+
var svg = $.sibling(div, 2);
13+
var custom_element = $.sibling(svg, 2);
14+
var div_1 = $.sibling(custom_element, 2);
1515

1616
$.template_effect(() => $.set_attribute(div_1, "foobar", y()));
1717

18-
var svg_1 = $.sibling($.sibling(div_1));
18+
var svg_1 = $.sibling(div_1, 2);
1919

2020
$.template_effect(() => $.set_attribute(svg_1, "viewBox", y()));
2121

22-
var custom_element_1 = $.sibling($.sibling(svg_1));
22+
var custom_element_1 = $.sibling(svg_1, 2);
2323

2424
$.template_effect(() => $.set_custom_element_data(custom_element_1, "fooBar", y()));
2525

packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ export default function Purity($$anchor) {
1313

1414
p.textContent = Math.max(min, Math.min(max, number));
1515

16-
var p_1 = $.sibling($.sibling(p));
16+
var p_1 = $.sibling(p, 2);
1717

1818
p_1.textContent = location.href;
1919

20-
var node = $.sibling($.sibling(p_1));
20+
var node = $.sibling(p_1, 2);
2121

2222
Child(node, { prop: encodeURIComponent(value) });
2323
$.append($$anchor, fragment);
Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import "svelte/internal/disclose-version";
22
import * as $ from "svelte/internal/client";
33

4-
var root = $.template(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header>`);
4+
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);
55

6-
export default function Skip_static_subtree($$anchor) {
7-
var header = root();
6+
export default function Skip_static_subtree($$anchor, $$props) {
7+
var fragment = root();
8+
var main = $.sibling($.first_child(fragment), 2);
9+
var h1 = $.child(main);
10+
var text = $.child(h1);
811

9-
$.append($$anchor, header);
12+
$.reset(h1);
13+
14+
var node = $.sibling(h1, 10);
15+
16+
$.html(node, () => $$props.content, false, false);
17+
$.reset(main);
18+
$.template_effect(() => $.set_text(text, $$props.title));
19+
$.append($$anchor, fragment);
1020
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as $ from "svelte/internal/server";
22

3-
export default function Skip_static_subtree($$payload) {
4-
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header>`;
3+
export default function Skip_static_subtree($$payload, $$props) {
4+
let { title, content } = $$props;
5+
6+
$$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>`;
57
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
<script>
2+
let { title, content } = $props();
3+
</script>
4+
15
<header>
26
<nav>
37
<a href="/">Home</a>
48
<a href="/away">Away</a>
59
</nav>
610
</header>
11+
12+
<main>
13+
<h1>{title}</h1>
14+
<div class="static">
15+
<p>we don't need to traverse these nodes</p>
16+
</div>
17+
<p>or</p>
18+
<p>these</p>
19+
<p>ones</p>
20+
{@html content}
21+
</main>

packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ export default function State_proxy_literal($$anchor) {
1818

1919
$.remove_input_defaults(input);
2020

21-
var input_1 = $.sibling($.sibling(input));
21+
var input_1 = $.sibling(input, 2);
2222

2323
$.remove_input_defaults(input_1);
2424

25-
var button = $.sibling($.sibling(input_1));
25+
var button = $.sibling(input_1, 2);
2626

2727
button.__click = [reset, str, tpl];
2828
$.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));

0 commit comments

Comments
 (0)