Skip to content

fix: make bind_this implementation more robust #10598

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 1 commit into from
Feb 22, 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/moody-sheep-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: make `bind_this` implementation more robust
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { regex_is_valid_identifier } from '../../../patterns.js';
import { javascript_visitors_runes } from './javascript-runes.js';
import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js';
import { walk } from 'zimmerframe';

/**
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
Expand Down Expand Up @@ -978,24 +979,11 @@ function serialize_inline_component(node, component_name, context) {

if (bind_this !== null) {
const prev = fn;
const assignment = b.assignment('=', bind_this, b.id('$$value'));
const bind_this_id = /** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
bind_this.type === 'Identifier'
? bind_this
: bind_this.type === 'MemberExpression' && bind_this.object.type === 'Identifier'
? bind_this.object
: undefined
);
fn = (node_id) =>
b.call(
'$.bind_this',
prev(node_id),
b.arrow(
[b.id('$$value')],
serialize_set_binding(assignment, context, () => context.visit(assignment))
),
bind_this_id
serialize_bind_this(
/** @type {import('estree').Identifier | import('estree').MemberExpression} */ (bind_this),
context,
prev(node_id)
);
}

Expand All @@ -1022,6 +1010,66 @@ function serialize_inline_component(node, component_name, context) {
return statements.length > 1 ? b.block(statements) : statements[0];
}

/**
* Serializes `bind:this` for components and elements.
* @param {import('estree').Identifier | import('estree').MemberExpression} bind_this
* @param {import('zimmerframe').Context<import('#compiler').SvelteNode, import('../types.js').ComponentClientTransformState>} context
* @param {import('estree').Expression} node
* @returns
*/
function serialize_bind_this(bind_this, context, node) {
let i = 0;
/** @type {Map<import('#compiler').Binding, [arg_idx: number, transformed: import('estree').Expression, expression: import('#compiler').Binding['expression']]>} */
const each_ids = new Map();
// Transform each reference to an each block context variable into a $$value_<i> variable
// by temporarily changing the `expression` of the corresponding binding.
// These $$value_<i> variables will be filled in by the bind_this runtime function through its last argument.
// Note that we only do this for each context variables, the consequence is that the value might be stale in
// some scenarios where the value is a member expression with changing computed parts or using a combination of multiple
// variables, but that was the same case in Svelte 4, too. Once legacy mode is gone completely, we can revisit this.
walk(
bind_this,
{},
{
Identifier(node) {
const binding = context.state.scope.get(node.name);
if (!binding || each_ids.has(binding)) return;

const associated_node = Array.from(context.state.scopes.entries()).find(
([_, scope]) => scope === binding?.scope
)?.[0];
if (associated_node?.type === 'EachBlock') {
each_ids.set(binding, [
i,
/** @type {import('estree').Expression} */ (context.visit(node)),
binding.expression
]);
binding.expression = b.id('$$value_' + i);
i++;
}
}
}
);

const bind_this_id = /** @type {import('estree').Expression} */ (context.visit(bind_this));
const ids = Array.from(each_ids.values()).map((id) => b.id('$$value_' + id[0]));
const assignment = b.assignment('=', bind_this, b.id('$$value'));
const update = serialize_set_binding(assignment, context, () => context.visit(assignment));

for (const [binding, [, , expression]] of each_ids) {
// reset expressions to what they were before
binding.expression = expression;
}

/** @type {import('estree').Expression[]} */
const args = [node, b.arrow([b.id('$$value'), ...ids], update), b.arrow([...ids], bind_this_id)];
if (each_ids.size) {
args.push(b.thunk(b.array(Array.from(each_ids.values()).map((id) => id[1]))));
}

return b.call('$.bind_this', ...args);
}

/**
* Creates a new block which looks roughly like this:
* ```js
Expand Down Expand Up @@ -2749,19 +2797,7 @@ export const template_visitors = {
}

case 'this':
call_expr = b.call(
`$.bind_this`,
state.node,
setter,
/** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
expression.type === 'Identifier'
? expression
: expression.type === 'MemberExpression' && expression.object.type === 'Identifier'
? expression.object
: undefined
)
);
call_expr = serialize_bind_this(node.expression, context, state.node);
break;
case 'textContent':
case 'innerHTML':
Expand Down
69 changes: 38 additions & 31 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
} from './reconciler.js';
import {
destroy_signal,
is_signal,
push_destroy_fn,
execute_effect,
untrack,
Expand Down Expand Up @@ -79,7 +78,6 @@ import { run } from '../common.js';
import { bind_transition, trigger_transitions } from './transitions.js';
import { mutable_source, source } from './reactivity/sources.js';
import { safe_equal, safe_not_equal } from './reactivity/equality.js';
import { STATE_SYMBOL } from './constants.js';

/** @type {Set<string>} */
const all_registerd_events = new Set();
Expand Down Expand Up @@ -1321,39 +1319,48 @@ export function bind_prop(props, prop, value) {
}
}

/**
* @param {unknown} value
*/
function is_state_object(value) {
return value != null && typeof value === 'object' && STATE_SYMBOL in value;
}

/**
* @param {Element} element_or_component
* @param {(value: unknown) => void} update
* @param {import('./types.js').MaybeSignal} binding
* @param {(value: unknown, ...parts: unknown[]) => void} update
* @param {(...parts: unknown[]) => unknown} get_value
* @param {() => unknown[]} [get_parts] Set if the this binding is used inside an each block,
* returns all the parts of the each block context that are used in the expression
* @returns {void}
*/
export function bind_this(element_or_component, update, binding) {
render_effect(() => {
// If we are reading from a proxied state binding, then we don't need to untrack
// the update function as it will be fine-grain.
if (is_state_object(binding) || (is_signal(binding) && is_state_object(binding.v))) {
update(element_or_component);
} else {
untrack(() => update(element_or_component));
}
return () => {
// Defer to the next tick so that all updates can be reconciled first.
// This solves the case where one variable is shared across multiple this-bindings.
render_effect(() => {
untrack(() => {
if (!is_signal(binding) || binding.v === element_or_component) {
update(null);
}
});
});
};
export function bind_this(element_or_component, update, get_value, get_parts) {
/** @type {unknown[]} */
let old_parts;
/** @type {unknown[]} */
let parts;

const e = effect(() => {
old_parts = parts;
// We only track changes to the parts, not the value itself to avoid unnecessary reruns.
parts = get_parts?.() || [];

untrack(() => {
if (element_or_component !== get_value(...parts)) {
update(element_or_component, ...parts);
// If this is an effect rerun (cause: each block context changes), then nullfiy the binding at
// the previous position if it isn't already taken over by a different effect.
if (old_parts && get_value(...old_parts) === element_or_component) {
update(null, ...old_parts);
}
}
});
});

// Add effect teardown (likely causes: if block became false, each item removed, component unmounted).
// In these cases we need to nullify the binding only if we detect that the value is still the same.
// If not, that means that another effect has now taken over the binding.
push_destroy_fn(e, () => {
// Defer to the next tick so that all updates can be reconciled first.
// This solves the case where one variable is shared across multiple this-bindings.
effect(() => {
if (get_value(...parts) === element_or_component) {
update(null, ...parts);
}
});
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ export function execute_effect(signal) {

function infinite_loop_guard() {
if (flush_count > 100) {
flush_count = 0;
throw new Error(
'ERR_SVELTE_TOO_MANY_UPDATES' +
(DEV
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tick } from 'svelte';
import { test } from '../../test';
import { create_deferred } from '../../../helpers.js';

Expand All @@ -22,7 +23,8 @@ export default test({
deferred.resolve(42);

return deferred.promise
.then(() => {
.then(async () => {
await tick();
assert.htmlEqual(target.innerHTML, '<button>click me</button>');

const { button } = component;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ export default test({
component.items = ['foo', 'baz'];
assert.equal(component.divs.length, 3, 'the divs array is still 3 long');
assert.equal(component.divs[2], null, 'the last div is unregistered');
// Different from Svelte 3
assert.equal(component.ps[1], null, 'the second p is unregistered');
// Different from Svelte 3
assert.equal(component.spans['-baz2'], null, 'the baz span is unregistered');
assert.equal(component.ps[2], null, 'the last p is unregistered');
assert.equal(component.spans['-bar1'], null, 'the bar span is unregistered');
assert.equal(component.hrs.bar, null, 'the bar hr is unregistered');

divs = target.querySelectorAll('div');
Expand All @@ -58,22 +56,17 @@ export default test({
assert.equal(e, divs[i], `div ${i} is still correct`);
});

// TODO: unsure if need these two tests to pass, as the logic between Svelte 3
// and Svelte 5 is different for these cases.

// elems = target.querySelectorAll('span');
// component.items.forEach((e, i) => {
// assert.equal(
// component.spans[`-${e}${i}`],
// elems[i],
// `span -${e}${i} is still correct`,
// );
// });
spans = target.querySelectorAll('span');
// @ts-ignore
component.items.forEach((e, i) => {
assert.equal(component.spans[`-${e}${i}`], spans[i], `span -${e}${i} is still correct`);
});

// elems = target.querySelectorAll('p');
// component.ps.forEach((e, i) => {
// assert.equal(e, elems[i], `p ${i} is still correct`);
// });
ps = target.querySelectorAll('p');
// @ts-ignore
component.ps.forEach((e, i) => {
assert.equal(e, ps[i], `p ${i} is still correct`);
});

hrs = target.querySelectorAll('hr');
// @ts-ignore
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
Expand All @@ -15,7 +16,8 @@ export default test({
assert.equal(window.getComputedStyle(div).opacity, '0');

raf.tick(600);
assert.equal(component.div, undefined);
assert.equal(target.querySelector('div'), undefined);
flushSync();
assert.equal(component.div, undefined);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { tick } from 'svelte';
import { test } from '../../test';

/** @param {number | null} selected */
function get_html(selected) {
return `
<button>1</button>
<button>2</button>
<button>3</button>

<hr></hr>

${selected !== null ? `<div>${selected}</div>` : ''}

<hr></hr>

<p>${selected ?? '...'}</p>
`;
}

export default test({
html: get_html(null),

async test({ assert, target }) {
const [btn1, btn2, btn3] = target.querySelectorAll('button');

await btn1?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(1));

await btn2?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(2));

await btn1?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(1));

await btn3?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(3));
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script>
import { tick } from 'svelte';

let selected = $state(-1);
let current = $state();

let div; // explicitly no $state
</script>

{#each [1, 2, 3] as n, i}
<button
onclick={async () => {
selected = i;
await tick();
current = div?.textContent;
}}
>{n}</button>
{/each}

<hr />

{#each [1, 2, 3] as n, i}
{#if selected === i}
<div bind:this={div}>{n}</div>
{/if}
{/each}

<hr />

<p>{current ?? '...'}</p>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function Bind_this($$anchor, $$props) {
var fragment = $.comment($$anchor);
var node = $.child_frag(fragment);

$.bind_this(Foo(node, {}), ($$value) => foo = $$value, foo);
$.bind_this(Foo(node, {}), ($$value) => foo = $$value, () => foo);
$.close_frag($$anchor, fragment);
$.pop();
}