Merge branch 'main' into tweak-parser

tweak-parser
Rich Harris 3 days ago
commit 34d3d7735c

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure proper HMR updates for dynamic components

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: defer error boundary rendering in forks

@ -62,6 +62,8 @@ export const STATE_SYMBOL = Symbol('$state');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');
/** An anchor might change, via this symbol on the original anchor we can tell HMR about the updated anchor */
export const HMR_ANCHOR = Symbol('hmr anchor');
/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */
export const STALE_REACTION = new (class StaleReactionError extends Error {

@ -1,6 +1,6 @@
/** @import { Effect, TemplateNode } from '#client' */
import { FILENAME, HMR } from '../../../constants.js';
import { EFFECT_TRANSPARENT } from '#client/constants';
import { EFFECT_TRANSPARENT, HMR_ANCHOR } from '#client/constants';
import { hydrate_node, hydrating } from '../dom/hydration.js';
import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set, source } from '../reactivity/sources.js';
@ -15,10 +15,10 @@ export function hmr(fn) {
const current = source(fn);
/**
* @param {TemplateNode} anchor
* @param {TemplateNode} initial_anchor
* @param {any} props
*/
function wrapper(anchor, props) {
function wrapper(initial_anchor, props) {
let component = {};
let instance = {};
@ -26,6 +26,7 @@ export function hmr(fn) {
let effect;
let ran = false;
let anchor = initial_anchor;
block(() => {
if (component === (component = get(current))) {
@ -39,6 +40,8 @@ export function hmr(fn) {
}
effect = branch(() => {
anchor = /** @type {any} */ (anchor)[HMR_ANCHOR] ?? anchor;
// when the component is invalidated, replace it without transitions
if (ran) set_should_intro(false);

@ -35,7 +35,7 @@ import { queue_micro_task } from '../task.js';
import * as e from '../../errors.js';
import * as w from '../../warnings.js';
import { DEV } from 'esm-env';
import { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js';
import { Batch, current_batch, previous_batch, schedule_effect } from '../../reactivity/batch.js';
import { internal_set, source } from '../../reactivity/sources.js';
import { tag } from '../../dev/tracing.js';
import { createSubscriber } from '../../../../reactivity/create-subscriber.js';
@ -386,15 +386,29 @@ export class Boundary {
/** @param {unknown} error */
error(error) {
var onerror = this.#props.onerror;
let failed = this.#props.failed;
// If we have nothing to capture the error, or if we hit an error while
// rendering the fallback, re-throw for another boundary to handle
if (!onerror && !failed) {
if (!this.#props.onerror && !this.#props.failed) {
throw error;
}
if (current_batch?.is_fork) {
if (this.#main_effect) current_batch.skip_effect(this.#main_effect);
if (this.#pending_effect) current_batch.skip_effect(this.#pending_effect);
if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect);
current_batch.on_fork_commit(() => {
this.#handle_error(error);
});
} else {
this.#handle_error(error);
}
}
/**
* @param {unknown} error
*/
#handle_error(error) {
if (this.#main_effect) {
destroy_effect(this.#main_effect);
this.#main_effect = null;
@ -416,6 +430,8 @@ export class Boundary {
set_hydrate_node(skip_nodes());
}
var onerror = this.#props.onerror;
let failed = this.#props.failed;
var did_reset = false;
var calling_on_error = false;

@ -7,8 +7,10 @@ import {
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
import { HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
import { DEV } from 'esm-env';
/**
* @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@ -91,6 +93,12 @@ export class BranchManager {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
if (DEV) {
// Tell hmr.js about the anchor it should use for updates,
// since the initial one will be removed
/** @type {any} */ (offscreen.fragment.lastChild)[HMR_ANCHOR] = this.anchor;
}
// remove the anchor...
/** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove();

@ -265,6 +265,8 @@ export function run(thunks) {
for (const fn of thunks.slice(1)) {
promise = promise
.then(() => {
restore();
if (errored) {
throw errored.error;
}
@ -273,7 +275,6 @@ export function run(thunks) {
throw STALE_REACTION;
}
restore();
return fn();
})
.catch(handle_error);

@ -120,6 +120,12 @@ export class Batch {
*/
#discard_callbacks = new Set();
/**
* Callbacks that should run only when a fork is committed.
* @type {Set<(batch: Batch) => void>}
*/
#fork_commit_callbacks = new Set();
/**
* Async effects that are currently in flight
* @type {Map<Effect, number>}
@ -489,6 +495,7 @@ export class Batch {
discard() {
for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear();
this.#fork_commit_callbacks.clear();
batches.delete(this);
}
@ -686,6 +693,16 @@ export class Batch {
this.#discard_callbacks.add(fn);
}
/** @param {(batch: Batch) => void} fn */
on_fork_commit(fn) {
this.#fork_commit_callbacks.add(fn);
}
run_fork_commit_callbacks() {
for (const fn of this.#fork_commit_callbacks) fn(this);
this.#fork_commit_callbacks.clear();
}
settled() {
return (this.#deferred ??= deferred()).promise;
}
@ -1212,6 +1229,10 @@ export function fork(fn) {
source.wv = increment_write_version();
}
batch.activate();
batch.run_fork_commit_callbacks();
batch.deactivate();
// trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we
// can't just encounter them during traversal, we need to

@ -0,0 +1,33 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [show, commit] = target.querySelectorAll('button');
show.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>show</button>
<button>commit</button>
<button>discard</button>
`
);
commit.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>show</button>
<button>commit</button>
<button>discard</button>
failed
`
);
}
});

@ -0,0 +1,18 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let f;
</script>
<button onclick={() => f = fork(() => show = true)}>show</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => f.discard()}>discard</button>
<svelte:boundary>
{#if show}
{await Promise.reject('boom')}
{/if}
{#snippet failed()}
failed
{/snippet}
</svelte:boundary>

@ -0,0 +1,30 @@
import { flushSync } from 'svelte';
import { HMR } from 'svelte/internal/client';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true,
hmr: true
},
async test({ assert, target }) {
const [btn] = target.querySelectorAll('button');
btn.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<button>show</button> component`);
// Simulate HMR swap on the child component.
const hidden = './_output/client/Component' + '.svelte.js';
const mod = await import(/* vite-ignore */ hidden);
const hmr_data = mod.default[HMR];
const fake_incoming = {
// Fake a new component, else HMR source's equality check will ignore the update
[HMR]: { fn: /** @param {any} args */ (...args) => hmr_data.fn(...args), current: null }
};
hmr_data.update(fake_incoming);
flushSync();
assert.htmlEqual(target.innerHTML, `<button>show</button> component`);
}
});

@ -0,0 +1,9 @@
<script>
import Component from "./Component.svelte";
let C = $state(null);
</script>
<button onclick={() => C = Component}>show</button>
<C />

@ -21,7 +21,7 @@
"polka": "^1.0.0-next.25",
"svelte": "workspace:*",
"tinyglobby": "^0.2.12",
"vite": "^7.1.11",
"vite": "^7.3.2",
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-inspect": "^11.3.3"
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save