diff --git a/.changeset/ten-trainers-juggle.md b/.changeset/ten-trainers-juggle.md new file mode 100644 index 0000000000..d76e5108a0 --- /dev/null +++ b/.changeset/ten-trainers-juggle.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: skip over static nodes in compiled client code diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index c5b7c29216..cbe0272827 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -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} 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; } diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index b86ea77947..76e5ca3cb2 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -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; diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index ef070bf288..846acc67a6 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -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())); diff --git a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js index 8a2daa877f..e054bd0c4a 100644 --- a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js @@ -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); diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js index 20d29d5359..5eacd941ea 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js @@ -1,10 +1,20 @@ import "svelte/internal/disclose-version"; import * as $ from "svelte/internal/client"; -var root = $.template(`
`); +var root = $.template(`

we don't need to traverse these nodes

or

these

ones

`, 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); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js index 77851c330e..8ea5fc9b13 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/server/index.svelte.js @@ -1,5 +1,7 @@ import * as $ from "svelte/internal/server"; -export default function Skip_static_subtree($$payload) { - $$payload.out += `
`; +export default function Skip_static_subtree($$payload, $$props) { + let { title, content } = $$props; + + $$payload.out += `

${$.escape(title)}

we don't need to traverse these nodes

or

these

ones

${$.html(content)}
`; } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/index.svelte b/packages/svelte/tests/snapshot/samples/skip-static-subtree/index.svelte index f5ee042585..001196a879 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/index.svelte +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/index.svelte @@ -1,6 +1,21 @@ + +
+ +
+

{title}

+
+

we don't need to traverse these nodes

+
+

or

+

these

+

ones

+ {@html content} +
diff --git a/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js index 8f12fd7913..bfd57166d8 100644 --- a/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client/index.svelte.js @@ -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));