From 5b65a637aa389e714ce3e02efef7797525bd8552 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 11 Feb 2026 11:27:39 +0100 Subject: [PATCH] chore: create separate Boundary class --- .../svelte/src/internal/server/renderer.js | 276 +++++++++++------- 1 file changed, 163 insertions(+), 113 deletions(-) diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 05be58060a..8a6dc1665a 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -18,9 +18,148 @@ import { noop } from '../shared/utils.js'; /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ /** - * @typedef {string | Renderer} RendererItem + * @typedef {string | Renderer | Boundary} RendererItem */ +/** + * Wraps a child {@link Renderer} to form an error boundary. Catches errors + * from children (both synchronous and async) and renders the `failed` snippet + * with the transformed error instead. + */ +class Boundary { + /** @type {Renderer} */ + renderer; + + /** @type {Renderer} */ + #parent; + + /** @type {(renderer: Renderer, error: unknown, reset: () => void) => void} */ + #failed; + + /** @type {(error: unknown) => unknown} */ + #onerror; + + /** + * @param {Renderer} parent + * @param {(renderer: Renderer, error: unknown, reset: () => void) => void} failed + * @param {(error: unknown) => unknown} onerror + */ + constructor(parent, failed, onerror) { + this.#parent = parent; + this.renderer = new Renderer(parent.global, parent); + this.#failed = failed; + this.#onerror = onerror; + } + + /** + * Run `children_fn` inside the boundary, catching synchronous errors. + * Async errors (from nested child renderers) are caught during collection + * @param {(renderer: Renderer) => MaybePromise} children_fn + */ + run(children_fn) { + const parent_context = ssr_context; + + set_ssr_context({ + ...ssr_context, + p: parent_context, + c: null, + r: this.renderer + }); + + try { + const result = children_fn(this.renderer); + + set_ssr_context(parent_context); + + if (result instanceof Promise) { + if (this.renderer.global.mode === 'sync') { + e.await_invalid(); + } + result.catch(noop); + this.renderer.promise = result; + } + } catch (error) { + set_ssr_context(parent_context); + this.#handle_sync_error(error); + } + } + + /** + * Handle a synchronous error thrown by `children_fn`. Runs the error + * through `onerror` and replaces the child renderer with one containing + * the failed snippet. + * @param {unknown} error + */ + #handle_sync_error(error) { + /** @type {unknown} */ + const result = this.#onerror(error); + + // Replace the renderer with one that contains the failed snippet + const failed_renderer = new Renderer(this.renderer.global, this.#parent); + + if (result instanceof Promise) { + if (this.renderer.global.mode === 'sync') { + e.await_invalid(); + } + + failed_renderer.promise = /** @type {Promise} */ (result).then((transformed) => + this.#render_failed(failed_renderer, transformed) + ); + failed_renderer.promise.catch(noop); + } else { + this.#render_failed(failed_renderer, result); + } + + this.renderer = failed_renderer; + } + + /** + * Collect the boundary's content asynchronously, catching errors from + * nested async children and rendering the failed snippet on error. + * @param {AccumulatedContent} content + */ + async collect_async(content) { + /** @type {AccumulatedContent} */ + const boundary_content = { head: '', body: '' }; + + try { + await this.renderer.collect_content_async(boundary_content); + // We merge afterwards to ensure failed renderers don't corrupt the content with partial output + content.head += boundary_content.head; + content.body += boundary_content.body; + } catch (error) { + const transformed = await this.#onerror(error); + + const failed_renderer = new Renderer(this.renderer.global, this.#parent); + failed_renderer.type = this.renderer.type; + + this.#render_failed(failed_renderer, transformed); + await failed_renderer.collect_content_async(content); + } + } + + /** + * Render the failed snippet into a renderer, serializing the transformed + * error into the hydration comment so the client can read it. + * @param {Renderer} renderer + * @param {unknown} error + */ + #render_failed(renderer, error) { + renderer.push(``); + this.#failed(renderer, error, noop); + renderer.push(BLOCK_CLOSE); + } + + /** + * @deprecated needed for legacy component bindings + */ + copy() { + const b = new Boundary(this.#parent, this.#failed, this.#onerror); + b.renderer = this.renderer.copy(); + return b; + } +} + /** * Renderers are basically a tree of `string | Renderer`s, where each `Renderer` in the tree represents * work that may or may not have completed. A renderer can be {@link collect}ed to aggregate the @@ -49,13 +188,6 @@ export class Renderer { */ #is_component_body = false; - /** - * If set, this renderer is an error boundary. When async collection - * of the children fails, the failed snippet is rendered instead. - * @type {{ failed: (renderer: Renderer, error: unknown, reset: () => void) => void; onerror: (error: unknown) => unknown } | null} - */ - #boundary = null; - /** * The type of string content that this renderer is accumulating. * @type {RendererType} @@ -223,79 +355,15 @@ export class Renderer { } /** - * Render children inside an error boundary. If the children throw and the API-level - * `onerror` transform handles the error (doesn't re-throw), the `failed` snippet is - * rendered instead. Otherwise the error propagates. + * Render children inside an error boundary. * - * @param {{ failed?: (renderer: Renderer, error: unknown, reset: () => void) => void }} props + * @param {{ failed: (renderer: Renderer, error: unknown, reset: () => void) => void }} props * @param {(renderer: Renderer) => MaybePromise} children_fn */ boundary(props, children_fn) { - // Create a child renderer for the boundary content. - // Mark it as a boundary so that #collect_content_async can catch - // errors from nested async children and render the failed snippet. - const child = new Renderer(this.global, this); - this.#out.push(child); - - if (props.failed) { - child.#boundary = { - failed: props.failed, - onerror: this.global.onerror - }; - } - - const parent_context = ssr_context; - - set_ssr_context({ - ...ssr_context, - p: parent_context, - c: null, - r: child - }); - - try { - const result = children_fn(child); - - set_ssr_context(parent_context); - - if (result instanceof Promise) { - if (child.global.mode === 'sync') { - e.await_invalid(); - } - result.catch(noop); - child.promise = result; - } - } catch (error) { - // synchronous errors are handled here, async errors will be handled in #collect_content_async - set_ssr_context(parent_context); - - const failed_snippet = props.failed; - - if (!failed_snippet) throw error; - - const result = this.global.onerror(error); - - if (result instanceof Promise) { - if (this.global.mode === 'sync') { - e.await_invalid(); - } - - child.#out.length = 0; - child.#boundary = null; - child.promise = /** @type {Promise} */ (result).then((transformed) => { - child.#out.push(``); - failed_snippet(child, transformed, noop); - child.#out.push(BLOCK_CLOSE); - }); - child.promise.catch(noop); - } else { - child.#out.length = 0; - child.#boundary = null; - child.#out.push(``); - failed_snippet(child, result, noop); - child.#out.push(BLOCK_CLOSE); - } - } + const b = new Boundary(this, props.failed, this.global.onerror); + this.#out.push(b); + b.run(children_fn); } /** @@ -373,7 +441,7 @@ export class Renderer { body(r); if (this.global.mode === 'async') { - return r.#collect_content_async().then((content) => { + return r.collect_content_async().then((content) => { close(renderer, content.body.replaceAll('', ''), content); }); } else { @@ -402,7 +470,7 @@ export class Renderer { fn(r); if (renderer.global.mode === 'async') { - return r.#collect_content_async().then((content) => { + return r.collect_content_async().then((content) => { close(content.head); }); } else { @@ -442,7 +510,7 @@ export class Renderer { */ copy() { const copy = new Renderer(this.global, this.#parent); - copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item)); + copy.#out = this.#out.map((item) => (typeof item !== 'string' ? item.copy() : item)); copy.promise = this.promise; return copy; } @@ -462,6 +530,8 @@ export class Renderer { this.#out = other.#out.map((item) => { if (item instanceof Renderer) { item.subsume(item); + } else if (item instanceof Boundary) { + item.renderer.subsume(item.renderer); } return item; }); @@ -571,8 +641,10 @@ export class Renderer { */ *#traverse_components() { for (const child of this.#out) { - if (typeof child !== 'string') { + if (child instanceof Renderer) { yield* child.#traverse_components(); + } else if (child instanceof Boundary) { + yield* child.renderer.#traverse_components(); } } if (this.#is_component_body) { @@ -592,6 +664,8 @@ export class Renderer { for (const child of this.#out) { if (child instanceof Renderer && !child.#is_component_body) { yield* child.#collect_ondestroy(); + } else if (child instanceof Boundary) { + yield* child.renderer.#collect_ondestroy(); } } } @@ -630,7 +704,7 @@ export class Renderer { try { const renderer = Renderer.#open_render('async', component, options); - const content = await renderer.#collect_content_async(); + const content = await renderer.collect_content_async(); const hydratables = await renderer.#collect_hydratables(); if (hydratables !== null) { content.head = hydratables + content.head; @@ -653,6 +727,8 @@ export class Renderer { content[this.type] += item; } else if (item instanceof Renderer) { item.#collect_content(content); + } else if (item instanceof Boundary) { + item.renderer.#collect_content(content); } } @@ -661,46 +737,20 @@ export class Renderer { /** * Collect all of the code from the `out` array and return it as a string. - * @param {AccumulatedContent} content + * @param {AccumulatedContent} [content] * @returns {Promise} */ - async #collect_content_async(content = { head: '', body: '' }) { + async collect_content_async(content = { head: '', body: '' }) { await this.promise; // no danger to sequentially awaiting stuff in here; all of the work is already kicked off for (const item of this.#out) { if (typeof item === 'string') { content[this.type] += item; + } else if (item instanceof Boundary) { + await item.collect_async(content); } else if (item instanceof Renderer) { - if (item.#boundary) { - // This renderer is an error boundary - collect into a separate - // accumulator so we can discard partial content on error - /** @type {AccumulatedContent} */ - const boundary_content = { head: '', body: '' }; - - try { - await item.#collect_content_async(boundary_content); - // Success - merge into the main content - content.head += boundary_content.head; - content.body += boundary_content.body; - } catch (error) { - const { failed, onerror } = item.#boundary; - - let transformed = await onerror(error); - - // Render the failed snippet instead of the partial children content - const failed_renderer = new Renderer(item.global, item); - failed_renderer.type = item.type; - failed_renderer.#out.push( - `` - ); - failed(failed_renderer, transformed, noop); - failed_renderer.#out.push(BLOCK_CLOSE); - await failed_renderer.#collect_content_async(content); - } - } else { - await item.#collect_content_async(content); - } + await item.collect_content_async(content); } }