Skip to content

feat: more efficient text-only fragments #12864

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

Merged
merged 5 commits into from
Aug 15, 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/hot-tips-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: more efficient text-only fragments
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ export function Fragment(node, context) {
} else if (is_single_child_not_needing_template) {
context.visit(trimmed[0], state);
body.push(...state.before_init, ...state.init);
} else if (trimmed.length === 1 && trimmed[0].type === 'Text') {
const id = b.id(context.state.scope.generate('text'));
body.push(
b.var(id, b.call('$.text', b.literal(trimmed[0].data))),
...state.before_init,
...state.init
);
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else if (trimmed.length > 0) {
const id = b.id(context.state.scope.generate('fragment'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import {
build_style_directives
} from './shared/element.js';
import { process_children } from './shared/fragment.js';
import { build_render_statement, build_update, build_update_assignment } from './shared/utils.js';
import {
build_render_statement,
build_template_literal,
build_update,
build_update_assignment
} from './shared/utils.js';
import { visit_event_attribute } from './shared/events.js';

/**
Expand Down Expand Up @@ -320,28 +325,43 @@ export function RegularElement(node, context) {
context.visit(node, child_state);
}

/** @type {Expression} */
let arg = context.state.node;

// If `hydrate_node` is set inside the element, we need to reset it
// after the element has been hydrated
let needs_reset = trimmed.some((node) => node.type !== 'Text');
// special case — if an element that only contains text, we don't need
// to descend into it if the text is non-reactive
const text_content =
trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag') &&
trimmed.some((node) => node.type === 'ExpressionTag') &&
build_template_literal(trimmed, context.visit, child_state);

// The same applies if it's a `<template>` element, since we need to
// set the value of `hydrate_node` to `node.content`
if (node.name === 'template') {
needs_reset = true;
child_state.init.push(b.stmt(b.call('$.hydrate_template', arg)));
arg = b.member(arg, b.id('content'));
}
if (text_content && !text_content.has_state) {
child_state.init.push(
b.stmt(
b.assignment('=', b.member(context.state.node, b.id('textContent')), text_content.value)
)
);
} else {
/** @type {Expression} */
let arg = context.state.node;

// If `hydrate_node` is set inside the element, we need to reset it
// after the element has been hydrated
let needs_reset = trimmed.some((node) => node.type !== 'Text');

// The same applies if it's a `<template>` element, since we need to
// set the value of `hydrate_node` to `node.content`
if (node.name === 'template') {
needs_reset = true;
child_state.init.push(b.stmt(b.call('$.hydrate_template', arg)));
arg = b.member(arg, b.id('content'));
}

process_children(trimmed, () => b.call('$.child', arg), true, {
...context,
state: child_state
});
process_children(trimmed, () => b.call('$.child', arg), true, {
...context,
state: child_state
});

if (needs_reset) {
child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
if (needs_reset) {
child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
}
}

if (has_declaration) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,53 +34,26 @@ export function process_children(nodes, expression, is_element, { visit, state }
state.template.push(node.raw);
return;
}
}

state.template.push(' ');
const text_id = get_node_id(expression(true), state, 'text');

const text_id = get_node_id(expression(true), state, 'text');
state.template.push(' ');

const update = b.stmt(
b.call('$.set_text', text_id, /** @type {Expression} */ (visit(node.expression, state)))
);
const { has_state, has_call, value } = build_template_literal(sequence, visit, state);

if (node.metadata.expression.has_call && !within_bound_contenteditable) {
state.init.push(build_update(update));
} else if (node.metadata.expression.has_state && !within_bound_contenteditable) {
state.update.push(update);
} else {
state.init.push(
b.stmt(
b.assignment(
'=',
b.member(text_id, b.id('nodeValue')),
/** @type {Expression} */ (visit(node.expression))
)
)
);
}
const update = b.stmt(b.call('$.set_text', text_id, value));

expression = (is_text) =>
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
if (has_call && !within_bound_contenteditable) {
state.init.push(build_update(update));
} else if (has_state && !within_bound_contenteditable) {
state.update.push(update);
} else {
const text_id = get_node_id(expression(true), state, 'text');

state.template.push(' ');

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

const update = b.stmt(b.call('$.set_text', text_id, value));

if (has_call && !within_bound_contenteditable) {
state.init.push(build_update(update));
} else if (has_state && !within_bound_contenteditable) {
state.update.push(update);
} else {
state.init.push(b.stmt(b.assignment('=', b.member(text_id, b.id('nodeValue')), value)));
}

expression = (is_text) =>
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
state.init.push(b.stmt(b.assignment('=', b.member(text_id, b.id('nodeValue')), value)));
}

expression = (is_text) =>
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
}

for (let i = 0; i < nodes.length; i += 1) {
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/dom/blocks/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import { clear_text_content, empty } from '../operations.js';
import { clear_text_content, create_text } from '../operations.js';
import {
block,
branch,
Expand Down Expand Up @@ -117,7 +117,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f

anchor = hydrating
? set_hydrate_node(/** @type {Comment | Text} */ (parent_node.firstChild))
: parent_node.appendChild(empty());
: parent_node.appendChild(create_text());
}

if (hydrating) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import { empty } from '../operations.js';
import { create_text } from '../operations.js';
import {
block,
branch,
Expand Down Expand Up @@ -119,7 +119,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
// If hydrating, use the existing ssr comment as the anchor so that the
// inner open and close methods can pick up the existing nodes correctly
var child_anchor = /** @type {TemplateNode} */ (
hydrating ? element.firstChild : element.appendChild(empty())
hydrating ? element.firstChild : element.appendChild(create_text())
);

if (hydrating) {
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/dom/blocks/svelte-head.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @import { TemplateNode } from '#client' */
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { empty } from '../operations.js';
import { create_text } from '../operations.js';
import { block } from '../../reactivity/effects.js';
import { HEAD_EFFECT } from '../../constants.js';
import { HYDRATION_START } from '../../../../constants.js';
Expand Down Expand Up @@ -52,7 +52,7 @@ export function head(render_fn) {
}

if (!hydrating) {
anchor = document.head.appendChild(empty());
anchor = document.head.appendChild(create_text());
}

try {
Expand Down
15 changes: 9 additions & 6 deletions packages/svelte/src/internal/client/dom/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ export function init_operations() {
}
}

/** @returns {Text} */
export function empty() {
return document.createTextNode('');
/**
* @param {string} value
* @returns {Text}
*/
export function create_text(value = '') {
return document.createTextNode(value);
}

/**
Expand All @@ -65,7 +68,7 @@ export function child(node) {

// Child can be null if we have an element with a single child, like `<p>{text}</p>`, where `text` is empty
if (child === null) {
child = hydrate_node.appendChild(empty());
child = hydrate_node.appendChild(create_text());
}

set_hydrate_node(child);
Expand All @@ -92,7 +95,7 @@ export function first_child(fragment, is_text) {
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && hydrate_node?.nodeType !== 3) {
var text = empty();
var text = create_text();

hydrate_node?.before(text);
set_hydrate_node(text);
Expand Down Expand Up @@ -121,7 +124,7 @@ export function sibling(node, is_text = false) {
// if a sibling {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && type !== 3) {
var text = empty();
var text = create_text();
next_sibling?.before(text);
set_hydrate_node(text);
return text;
Expand Down
11 changes: 6 additions & 5 deletions packages/svelte/src/internal/client/dom/template.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @import { Effect, EffectNodes, TemplateNode } from '#client' */
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
import { empty } from './operations.js';
import { create_text } from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { current_effect } from '../runtime.js';
import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js';
Expand Down Expand Up @@ -209,10 +209,11 @@ function run_scripts(node) {

/**
* Don't mark this as side-effect-free, hydration needs to walk all nodes
* @param {any} value
*/
export function text() {
export function text(value = '') {
if (!hydrating) {
var t = empty();
var t = create_text(value + '');
assign_nodes(t, t);
return t;
}
Expand All @@ -221,7 +222,7 @@ export function text() {

if (node.nodeType !== 3) {
// if an {expression} is empty during SSR, we need to insert an empty text node
node.before((node = empty()));
node.before((node = create_text()));
set_hydrate_node(node);
}

Expand All @@ -238,7 +239,7 @@ export function comment() {

var frag = document.createDocumentFragment();
var start = document.createComment('');
var anchor = empty();
var anchor = create_text();
frag.append(start, anchor);

assign_nodes(start, anchor);
Expand Down
12 changes: 4 additions & 8 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @import { ComponentContext, Effect, EffectNodes, TemplateNode } from '#client' */
/** @import { Component, ComponentType, SvelteComponent } from '../../index.js' */
import { DEV } from 'esm-env';
import { clear_text_content, empty, init_operations } from './dom/operations.js';
import { clear_text_content, create_text, init_operations } from './dom/operations.js';
import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';
import { push, pop, current_component_context, current_effect } from './runtime.js';
import { effect_root, branch } from './reactivity/effects.js';
Expand Down Expand Up @@ -43,13 +43,9 @@ export function set_should_intro(value) {
*/
export function set_text(text, value) {
// @ts-expect-error
const prev = (text.__t ??= text.nodeValue);

if (prev !== value) {
if (value !== (text.__t ??= text.nodeValue)) {
// @ts-expect-error
text.__t = value;
// It's faster to make the value a string rather than passing a non-string to nodeValue
text.nodeValue = value == null ? '' : value + '';
text.nodeValue = text.__t = value;
}
}

Expand Down Expand Up @@ -78,7 +74,7 @@ export function set_text(text, value) {
* @returns {Exports}
*/
export function mount(component, options) {
const anchor = options.anchor ?? options.target.appendChild(empty());
const anchor = options.anchor ?? options.target.appendChild(create_text());
return _mount(component, { ...options, anchor });
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<h1>call +636-555-3226 now</h1>
<h1>call +636-555-3226 now<span>!</span></h1>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!--[--><h1>call <a href="tel:+636-555-3226">+636-555-3226</a> now</h1><!--]-->
<!--[--><h1>call <a href="tel:+636-555-3226">+636-555-3226</a> now<span>!</span></h1><!--]-->
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
const message = `call +636-555-3226 now`;
</script>

<h1>{message}</h1>
<h1>{message}<span>!</span></h1>
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,21 @@ import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";
import TextInput from './Child.svelte';

var root_1 = $.template(`Something`, 1);
var root = $.template(`<!> `, 1);

export default function Bind_component_snippet($$anchor) {
const snippet = ($$anchor) => {
$.next();

var fragment = root_1();
var text = $.text("Something");

$.append($$anchor, fragment);
$.append($$anchor, text);
};

let value = $.source('');
const _snippet = snippet;
var fragment_1 = root();
var node = $.first_child(fragment_1);
var fragment = root();
var node = $.first_child(fragment);

TextInput(node, {
get value() {
Expand All @@ -28,8 +27,8 @@ export default function Bind_component_snippet($$anchor) {
}
});

var text = $.sibling(node, true);
var text_1 = $.sibling(node, true);

$.template_effect(() => $.set_text(text, ` value: ${$.get(value) ?? ""}`));
$.append($$anchor, fragment_1);
$.template_effect(() => $.set_text(text_1, ` value: ${$.get(value) ?? ""}`));
$.append($$anchor, fragment);
}
Loading
Loading