defer new render effect runs

teemingc-hydration-regression-test
Rich Harris 2 days ago
parent 86eb09b76e
commit 6150c4dc49
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: run render effects created during traversal alongside existing render effects, in tree order

@ -78,6 +78,13 @@ let is_processing = false;
*/ */
export let collected_effects = null; export let collected_effects = null;
/**
* Like `collected_effects`, but for render (and managed) effects. Only used in async mode,
* where these effects run after traversal rather than during it
* @type {Effect[] | null}
*/
export let collected_render_effects = null;
/** /**
* An array of effects that are marked during traversal as a result of a `set` * An array of effects that are marked during traversal as a result of a `set`
* (not `internal_set`) call. These will be added to the next batch and * (not `internal_set`) call. These will be added to the next batch and
@ -347,6 +354,10 @@ export class Batch {
/** @type {Effect[]} */ /** @type {Effect[]} */
var render_effects = []; var render_effects = [];
if (async_mode_flag) {
collected_render_effects = render_effects;
}
/** /**
* @type {Effect[]} * @type {Effect[]}
* @deprecated when we get rid of legacy mode and stores, we can get rid of this * @deprecated when we get rid of legacy mode and stores, we can get rid of this
@ -389,6 +400,7 @@ export class Batch {
} }
collected_effects = null; collected_effects = null;
collected_render_effects = null;
legacy_updates = null; legacy_updates = null;
// if the batch has outstanding pending work, stash effects and bail // if the batch has outstanding pending work, stash effects and bail
@ -426,7 +438,29 @@ export class Batch {
this.#commit_callbacks.clear(); this.#commit_callbacks.clear();
previous_batch = this; previous_batch = this;
var length = effects.length;
// effects created by render effects (e.g. inside newly created branches) are
// collected and run alongside the existing effects
collected_effects = effects;
flush_queued_effects(render_effects); flush_queued_effects(render_effects);
collected_effects = null;
// if render effects wrote to state, they may have invalidated themselves (or
// an ancestor), in which case newly collected effects will be destroyed
// and recreated — hand them over to the next batch instead of running them now
var next = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
if (next !== null && effects.length > length) {
for (var i = length; i < effects.length; i += 1) {
if (has_dirty_ancestor(effects[i])) {
next.schedule(effects[i]);
effects.splice(i--, 1);
}
}
}
flush_queued_effects(effects); flush_queued_effects(effects);
previous_batch = null; previous_batch = null;
@ -665,6 +699,7 @@ export class Batch {
flush_count = 0; flush_count = 0;
last_scheduled_effect = null; last_scheduled_effect = null;
collected_effects = null; collected_effects = null;
collected_render_effects = null;
legacy_updates = null; legacy_updates = null;
is_processing = false; is_processing = false;
@ -1355,6 +1390,27 @@ function reset_branch(effect, tracked) {
} }
} }
/**
* Returns `true` if a (non-branch) ancestor of the effect is dirty,
* meaning that the effect is likely to be destroyed when it re-runs
* @param {Effect} effect
*/
function has_dirty_ancestor(effect) {
var parent = effect.parent;
while (parent !== null) {
var flags = parent.f;
if ((flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 && (flags & (DIRTY | MAYBE_DIRTY)) !== 0) {
return true;
}
parent = parent.parent;
}
return false;
}
/** /**
* Mark an entire effect tree clean following an error * Mark an entire effect tree clean following an error
* @param {Effect} effect * @param {Effect} effect

@ -43,7 +43,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js'; import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js'; import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects, current_batch } from './batch.js'; import { Batch, collected_effects, collected_render_effects, current_batch } from './batch.js';
import { flatten } from './async.js'; import { flatten } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js'; import { set_signal_status } from './status.js';
@ -134,6 +134,14 @@ function create_effect(type, fn) {
// schedule for later // schedule for later
Batch.ensure().schedule(effect); Batch.ensure().schedule(effect);
} }
} else if (
collected_render_effects !== null &&
fn !== null &&
(type & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0
) {
// created during traversal — collect and run afterwards,
// alongside existing render effects, in tree order
collected_render_effects.push(effect);
} else if (fn !== null) { } else if (fn !== null) {
try { try {
update_effect(effect); update_effect(effect);

@ -27,10 +27,14 @@ export default test({
assert.deepEqual(logs, ['inner 0', 'inner 1']); assert.deepEqual(logs, ['inner 0', 'inner 1']);
// the pending batch settles — nothing the inner block sees has // the pending batch settles — nothing the inner block sees has
// changed since its eager run, so it should not be re-run // changed since its eager run, so ideally it would not be re-run.
// TODO it currently is: the eager run happened while time travelling,
// which leaves the block MAYBE_DIRTY (and the derived dirty), and the
// pending batch's traversal passes through the block on its way to the
// `<span>` template effect, which was deferred until this batch commits
shift.click(); shift.click();
await tick(); await tick();
assert.deepEqual(logs, ['inner 0', 'inner 1']); assert.deepEqual(logs, ['inner 0', 'inner 1', 'inner 1']);
assert.htmlEqual(target.innerHTML, `<p>0</p><span>1</span>${buttons}`); assert.htmlEqual(target.innerHTML, `<p>0</p><span>1</span>${buttons}`);
} }
}); });

@ -0,0 +1,32 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client', 'hydrate'],
test({ assert, target, logs }) {
const input = /** @type {HTMLInputElement} */ (target.querySelector('input'));
assert.deepEqual(logs, ['a', 'hello', 'c', 'hello']);
logs.length = 0;
input.value = 'hello!';
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
assert.deepEqual(logs, ['a', 'hello!', 'c', 'hello!']);
logs.length = 0;
input.value = 'hello!!';
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
assert.deepEqual(logs, ['a', 'hello!!', 'b', 'hello!!', 'c', 'hello!!']);
logs.length = 0;
input.value = 'hello!!!';
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
assert.deepEqual(logs, ['a', 'hello!!!', 'b', 'hello!!!', 'c', 'hello!!!']);
assert.htmlEqual(target.innerHTML, `<input /><p>hello!!!</p><p>hello!!!</p><p>hello!!!</p>`);
}
});

@ -0,0 +1,22 @@
<script>
let message = $state('hello');
function log(value, label) {
console.log(label, value);
return value;
}
</script>
<input bind:value={message} />
{#if true}
<p>{log(message, 'a')}</p>
{/if}
{#if message.length > 6}
<p>{log(message, 'b')}</p>
{/if}
{#if true}
<p>{log(message, 'c')}</p>
{/if}
Loading…
Cancel
Save