Merge remote-tracking branch 'origin/main' into svelte-custom-renderer

svelte-custom-renderer
paoloricciuti 2 months ago
commit ce6e71c695

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't (re)connect deriveds when read inside branch/root effects

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: skip unnecessary derived effect in earlier batch

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: avoid declaration tag warning in event handlers

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: transform computed keys in keyed `{#each}` destructuring patterns

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: chain preprocessor sourcemaps with an empty `sources[0]` instead of dropping them

@ -0,0 +1,47 @@
import assert from 'node:assert';
import * as $ from 'svelte/internal/client';
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
// Like `kairo_broad`, but each derived is also read by a block effect, as
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
export default () => {
let head = $.state(0);
let last = head;
let counter = 0;
const destroy = $.effect_root(() => {
for (let i = 0; i < 50; i++) {
let current = $.derived(() => {
return $.get(head) + i;
});
let current2 = $.derived(() => {
return $.get(current) + 1;
});
block(() => {
$.get(current2);
});
$.render_effect(() => {
$.get(current2);
counter++;
});
last = current2;
}
});
return {
destroy,
run() {
$.flush(() => {
$.set(head, 1);
});
counter = 0;
for (let i = 0; i < 50; i++) {
$.flush(() => {
$.set(head, i);
});
assert.equal($.get(last), i + 50);
}
assert.equal(counter, 50 * 50);
}
};
};

@ -0,0 +1,48 @@
import assert from 'node:assert';
import * as $ from 'svelte/internal/client';
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
let len = 50;
const iter = 50;
// Like `kairo_deep`, but the derived chain is also read by a block effect, as
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
export default () => {
let head = $.state(0);
let current = head;
for (let i = 0; i < len; i++) {
let c = current;
current = $.derived(() => {
return $.get(c) + 1;
});
}
let counter = 0;
const destroy = $.effect_root(() => {
block(() => {
$.get(current);
});
$.render_effect(() => {
$.get(current);
counter++;
});
});
return {
destroy,
run() {
$.flush(() => {
$.set(head, 1);
});
counter = 0;
for (let i = 0; i < iter; i++) {
$.flush(() => {
$.set(head, i);
});
assert.equal($.get(current), len + i);
}
assert.equal(counter, iter);
}
};
};

@ -18,7 +18,9 @@ export function visit_function(node, context) {
context.next({ context.next({
...context.state, ...context.state,
function_depth: context.state.function_depth + 1, // we generally want to use scope.function_depth unless we specifically increased
// that in state.function_depth (e.g. a derived)
function_depth: Math.max(context.state.scope.function_depth, context.state.function_depth) + 1,
expression: null expression: null
}); });
} }

@ -294,7 +294,10 @@ export function EachBlock(node, context) {
let key_function = b.id('$.index'); let key_function = b.id('$.index');
if (node.metadata.keyed) { if (node.metadata.keyed) {
const pattern = /** @type {Pattern} */ (node.context); // can only be keyed when a context is provided // can only be keyed when a context is provided
const pattern = /** @type {Pattern} */ (
context.visit(/** @type {Pattern} */ (node.context), key_state)
);
const expression = /** @type {Expression} */ ( const expression = /** @type {Expression} */ (
context.visit(/** @type {Expression} */ (node.key), key_state) context.visit(/** @type {Expression} */ (node.key), key_state)
); );

@ -311,6 +311,13 @@ function apply_preprocessor_sourcemap(filename, svelte_map, preprocessor_map_inp
typeof preprocessor_map_input === 'string' typeof preprocessor_map_input === 'string'
? JSON.parse(preprocessor_map_input) ? JSON.parse(preprocessor_map_input)
: preprocessor_map_input; : preprocessor_map_input;
// A preprocessor map with a missing/empty `sources[0]` (e.g. from a MagicString transform
// created without a `source` option) can't be matched against `filename` during combination,
// which silently drops the chain instead of erroring. Normalize it to `filename` first, the
// same way Vite treats an empty `sources[0]` as referring to the file being transformed.
if (preprocessor_map.sources?.length === 1 && !preprocessor_map.sources[0]) {
preprocessor_map.sources = [filename];
}
const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]); const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]);
// Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class, // Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class,
// we just tack on the extra properties. // we just tack on the extra properties.

@ -530,6 +530,12 @@ export class Batch {
const mark = (value) => { const mark = (value) => {
var reactions = value.reactions; var reactions = value.reactions;
if (reactions === null) return; if (reactions === null) return;
// skip if value is derived and is neither dirty nor maybe dirty. transitive
// deriveds (a derived depending on another derived) are only MAYBE_DIRTY, so
// we must continue traversing them to reach the effects that depend on them
if ((value.f & DERIVED) !== 0 && (value.f & (DIRTY | MAYBE_DIRTY)) === 0) {
return;
}
for (const reaction of reactions) { for (const reaction of reactions) {
var flags = reaction.f; var flags = reaction.f;

@ -62,6 +62,9 @@ import { set_signal_status, update_derived_status } from './reactivity/status.js
import * as w from './warnings.js'; import * as w from './warnings.js';
import { push_renderer } from './custom-renderer/state.js'; import { push_renderer } from './custom-renderer/state.js';
/**
* True if updating in an effect context that is reactive (i.e. not branch/root effects)
*/
let is_updating_effect = false; let is_updating_effect = false;
export let is_destroying_effect = false; export let is_destroying_effect = false;
@ -445,7 +448,7 @@ export function update_effect(effect) {
var was_updating_effect = is_updating_effect; var was_updating_effect = is_updating_effect;
active_effect = effect; active_effect = effect;
is_updating_effect = true; is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts
var pop_renderer = push_renderer(effect.r); var pop_renderer = push_renderer(effect.r);

@ -0,0 +1,28 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [increment, pop] = target.querySelectorAll('button');
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button> <button>pop</button> <p>Loading...</p>`
);
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button> <button>pop</button> <p>Loading...</p>`
);
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>increment</button> <button>pop</button> 2 2 1`);
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>increment</button> <button>pop</button> 2 2 1`);
}
});

@ -0,0 +1,29 @@
<script>
let count = $state(0);
let other = $state(0);
const queue = [];
let pending = $derived(defaultPending);
function push(v) {
return new Promise((resolve) => queue.push(() => resolve(v)));
}
</script>
<button onclick={() => {
if (count === 0) {
other++;
count++;
} else {
count++
}
}}>increment</button>
<button onclick={() => queue.pop()?.()}>pop</button>
{#snippet defaultPending()}
<p>Loading...</p>
{/snippet}
{#if count > 0}
<svelte:boundary {pending}>
{await push(count)} {count} {other}
</svelte:boundary>
{/if}

@ -0,0 +1,7 @@
<script>
let { labelKey, valueKey, options } = $props();
</script>
{#each options as { [labelKey]: label, [valueKey]: value } (value)}
<p>{label}: {value}</p>
{/each}

@ -0,0 +1,29 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
// https://github.com/sveltejs/svelte/issues/18519
export default test({
html: `
<button>reverse</button>
<p>1: a1</p>
<p>2: a2</p>
<p>3: a3</p>
`,
test({ assert, target }) {
const btn = target.querySelector('button');
ok(btn);
flushSync(() => btn.click());
assert.htmlEqual(
target.innerHTML,
`
<button>reverse</button>
<p>3: a3</p>
<p>2: a2</p>
<p>1: a1</p>
`
);
}
});

@ -0,0 +1,12 @@
<script>
import Child from './Child.svelte';
let options = $state([
{ a: 1, v: 'a1' },
{ a: 2, v: 'a2' },
{ a: 3, v: 'a3' }
]);
</script>
<button onclick={() => options.reverse()}>reverse</button>
<Child {options} labelKey="a" valueKey="v" />

@ -1503,4 +1503,22 @@ describe('signals', () => {
assert.deepEqual(log, ['inner destroyed', 'inner destroyed']); assert.deepEqual(log, ['inner destroyed', 'inner destroyed']);
}; };
}); });
test('derived read in an untracked context should not leak in deps reactions', () => {
return () => {
let s = state('hello');
let a = derived(() => $.get(s));
let b = derived(() => $.get(a));
let destroy = effect_root(() => {
$.get(b);
});
destroy();
// a was spuriously added to s.reactions via is_updating_effect
// even though the entire derived chain was read in an untracked context
assert.equal(s.reactions, null);
};
});
}); });

@ -0,0 +1,29 @@
import * as fs from 'node:fs';
import MagicString from 'magic-string';
import { test } from '../../test';
// Simulates a bundler plugin (e.g. a Vite plugin using `magic-string`) that transforms
// the Svelte source *before* it reaches `compile()`, and hands its own sourcemap to
// `compileOptions.sourcemap` — the documented way to let svelte chain an upstream map
// into its own output map. Crucially, the upstream map is generated *without* a `source`
// option, exactly like `new MagicString(code).generateMap()` — this yields a sourcemap
// whose `sources` is `['']`, which previously broke the chain entirely (see #18491)
// instead of being treated as "this file", causing every mapping through it to resolve
// to `{ source: null, line: null, column: null }`.
const input = fs.readFileSync(new URL('./input.svelte', import.meta.url), 'utf-8');
const src = new MagicString(input);
src.overwrite(
src.original.indexOf('count * 2'),
src.original.indexOf('count * 2') + 'count * 2'.length,
'count * 2',
{
storeName: false
}
);
export default test({
compileOptions: {
sourcemap: src.generateMap({ hires: true })
},
client: [{ str: 'let doubled' }]
});

@ -0,0 +1,6 @@
<script>
let count = 0;
let doubled = count * 2;
</script>
<button>clicks: {count}</button>

@ -7,3 +7,7 @@
{let e = $state(0), f = e} {let e = $state(0), f = e}
{a}{b}{c}{d}{e}{f} {a}{b}{c}{d}{e}{f}
<button onclick={() => {
console.log(a);
}}>a</button>
Loading…
Cancel
Save