fix: call `onerror` and provide a working `reset` when hydrating a failed boundary (#18556)

Fixes #18555

A boundary that failed during SSR hydrates via
`#hydrate_failed_content`, which never calls `onerror` and passes the
`failed` snippet a no-op `reset`. Both came in with #17672, whose docs
say `onerror` "will be called upon hydration with the deserialized error
object". Once hydrated as failed, the boundary can never leave that
state. Downstream this is what keeps SvelteKit's `+error.svelte` mounted
after navigating away from a server-rendered error page
(sveltejs/kit#16345).

This extracts the reset/onerror machinery from `#handle_error` into
`#create_reset` and uses it in the hydration path too. `onerror` is
invoked in a microtask because it may mutate state, which is disallowed
while hydrating. `#handle_error` already invokes it asynchronously, so
the timing matches the error path.

The tests flip a `recovered` flag before calling `reset`, since the
child would otherwise throw again. That is the intended retry pattern,
and the same one kit uses when it resets route boundaries on navigation.
Verified against kit end to end, hydrating a server-rendered error page
and navigating away now tears it down with no kit changes needed.
pull/18197/merge
Nic Polumeyv 6 days ago committed by GitHub
parent 2bace308e3
commit 3dde011d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: call `onerror` and provide a working `reset` when hydrating a failed boundary

@ -199,17 +199,68 @@ export class Boundary {
*/
#hydrate_failed_content(error) {
const failed = this.#props.failed;
const { reset, invoke_onerror } = this.#create_reset(error);
// `onerror` may mutate state, which is disallowed while hydrating
queue_micro_task(invoke_onerror);
if (!failed) return;
this.#failed_effect = branch(() => {
failed(
this.#anchor,
() => error,
() => () => {}
() => reset
);
});
}
/**
* Creates the `reset` function for a failed boundary, along with a function
* that invokes `onerror` with it (if provided)
* @param {unknown} error
* @returns {{ reset: () => void, invoke_onerror: () => void }}
*/
#create_reset(error) {
var did_reset = false;
var calling_on_error = false;
const reset = () => {
if (did_reset) {
w.svelte_boundary_reset_noop();
return;
}
did_reset = true;
if (calling_on_error) {
e.svelte_boundary_reset_onerror();
}
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}
this.#run(() => {
this.#render();
});
};
const invoke_onerror = () => {
try {
calling_on_error = true;
this.#props.onerror?.(error, reset);
calling_on_error = false;
} catch (err) {
invoke_error_boundary(err, this.#effect && this.#effect.parent);
}
};
return { reset, invoke_onerror };
}
#hydrate_pending_content() {
const pending = this.#props.pending;
if (!pending) return;
@ -429,43 +480,13 @@ 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;
const reset = () => {
if (did_reset) {
w.svelte_boundary_reset_noop();
return;
}
did_reset = true;
if (calling_on_error) {
e.svelte_boundary_reset_onerror();
}
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}
this.#run(() => {
this.#render();
});
};
/** @param {unknown} transformed_error */
const handle_error_result = (transformed_error) => {
try {
calling_on_error = true;
onerror?.(transformed_error, reset);
calling_on_error = false;
} catch (error) {
invoke_error_boundary(error, this.#effect && this.#effect.parent);
}
const { reset, invoke_onerror } = this.#create_reset(transformed_error);
invoke_onerror();
if (failed) {
this.#failed_effect = this.#run(() => {

@ -0,0 +1,19 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate'],
ssrHtml: '<p>failed: error</p> <button>reset</button>',
transformError: () => 'error',
test({ assert, target, logs }) {
// `onerror` is called upon hydration with the deserialized error
assert.deepEqual(logs, ['onerror: error']);
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.htmlEqual(target.innerHTML, '<p>recovered</p> <button>reset</button>');
}
});

@ -0,0 +1,29 @@
<script>
import Child from './child.svelte';
let recovered = $state(false);
let reset_fn = $state();
</script>
<svelte:boundary
onerror={(error, reset) => {
console.log(`onerror: ${error}`);
reset_fn = reset;
}}
>
{#if recovered}
<p>recovered</p>
{:else}
<Child />
{/if}
{#snippet failed(error)}
<p>failed: {error}</p>
{/snippet}
</svelte:boundary>
<button
onclick={() => {
recovered = true;
reset_fn();
}}>reset</button>

@ -0,0 +1,16 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate'],
ssrHtml: '<p>failed: error</p> <button>reset</button>',
transformError: () => 'error',
test({ assert, target }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.htmlEqual(target.innerHTML, '<p>recovered</p>');
}
});

@ -0,0 +1,22 @@
<script>
import Child from './child.svelte';
let recovered = $state(false);
</script>
<svelte:boundary>
{#if recovered}
<p>recovered</p>
{:else}
<Child />
{/if}
{#snippet failed(error, reset)}
<p>failed: {error}</p>
<button
onclick={() => {
recovered = true;
reset();
}}>reset</button>
{/snippet}
</svelte:boundary>
Loading…
Cancel
Save