From cf6d6c6d7bf304c533c14e1c8cd4ddc84993dc7a Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:53:06 -0400 Subject: [PATCH 1/2] chore: port the remaining remote error status readers to App.Error --- .changeset/remote-error-status.md | 5 +++++ packages/kit/src/runtime/app/server/remote/prerender.js | 6 +++--- .../runtime/client/remote-functions/query-live/iterator.js | 5 ++++- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .changeset/remote-error-status.md diff --git a/.changeset/remote-error-status.md b/.changeset/remote-error-status.md new file mode 100644 index 000000000000..b926f1ca4d57 --- /dev/null +++ b/.changeset/remote-error-status.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: read the error status from `App.Error` in the remaining remote-response readers diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index f21f7eb22c49..232aa2928062 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,5 +1,5 @@ /** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */ -/** @import { RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ +/** @import { RemoteFunctionResponse, RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { json, error } from '@sveltejs/kit'; import { get_request_store } from '@sveltejs/kit/internal/server'; @@ -109,10 +109,10 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { throw new Error('Prerendered response not found'); } - const prerendered = /** @type {RemoteFunctionResponse} */ await response.json(); + const prerendered = /** @type {RemoteFunctionResponse} */ (await response.json()); if (prerendered.type === 'error') { - error(prerendered.status, prerendered.error); + error(prerendered.error.status, prerendered.error); } return parse_remote_response(prerendered.data, state.transport)._; diff --git a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js index fc345ce4dae2..efee406ddead 100644 --- a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js +++ b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js @@ -37,7 +37,10 @@ export async function* create_live_iterator( error: response.statusText })); - throw new HttpError(result.status ?? response.status ?? 500, result.error); + throw new HttpError( + result.error?.status ?? result.status ?? response.status ?? 500, + result.error + ); } if (response.headers.get('content-type')?.includes('application/json')) { From d5bf95e8680e1f86be92d6ba8b4c84f020d2b1cf Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:38:31 -0400 Subject: [PATCH 2/2] fix: propagate errors from prerendered remote responses instead of re-running the function --- .../remote-prerender-error-propagation.md | 5 + .../runtime/app/server/remote/prerender.js | 20 ++-- .../app/server/remote/prerender.spec.js | 100 ++++++++++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 .changeset/remote-prerender-error-propagation.md create mode 100644 packages/kit/src/runtime/app/server/remote/prerender.spec.js diff --git a/.changeset/remote-prerender-error-propagation.md b/.changeset/remote-prerender-error-propagation.md new file mode 100644 index 000000000000..72c9c8a879de --- /dev/null +++ b/.changeset/remote-prerender-error-propagation.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: propagate errors from prerendered remote responses instead of re-running the function diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 232aa2928062..8a9aed187884 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,7 +1,8 @@ /** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse, RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; +import { HttpError } from '@sveltejs/kit/internal'; import { get_request_store } from '@sveltejs/kit/internal/server'; import { stringify, stringify_remote_arg } from '../../../shared.js'; import { noop } from '../../../../utils/functions.js'; @@ -99,25 +100,28 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; if (!state.prerendering && !__SVELTEKIT_DEV__ && !event.isRemoteRequest) { + /** @type {RemoteFunctionResponse | undefined} */ + let prerendered; + try { // TODO adapters can provide prerendered data more efficiently than // fetching from the public internet // `request.url` rather than `event.url`, which throws inside queries const response = await fetch(new URL(url, event.request.url).href); - if (!response.ok) { - throw new Error('Prerendered response not found'); + if (response.ok) { + prerendered = /** @type {RemoteFunctionResponse} */ (await response.json()); } + } catch { + // not available prerendered, fallback to normal function + } - const prerendered = /** @type {RemoteFunctionResponse} */ (await response.json()); - + if (prerendered) { if (prerendered.type === 'error') { - error(prerendered.error.status, prerendered.error); + throw new HttpError(prerendered.error.status, prerendered.error); } return parse_remote_response(prerendered.data, state.transport)._; - } catch { - // not available prerendered, fallback to normal function } } diff --git a/packages/kit/src/runtime/app/server/remote/prerender.spec.js b/packages/kit/src/runtime/app/server/remote/prerender.spec.js new file mode 100644 index 000000000000..79c4853b076f --- /dev/null +++ b/packages/kit/src/runtime/app/server/remote/prerender.spec.js @@ -0,0 +1,100 @@ +/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestState } from 'types' */ +import { expect, test, vi } from 'vitest'; +import { HttpError } from '@sveltejs/kit/internal'; +import { prerender } from './prerender.js'; +import { stringify } from '../../../shared.js'; + +const store = vi.hoisted(() => ({ current: /** @type {any} */ (null) })); + +vi.mock(import('@sveltejs/kit/internal/server'), async (actualPromise) => { + const actual = await actualPromise(); + return { + ...actual, + get_request_store: () => store.current + }; +}); + +vi.stubGlobal('__SVELTEKIT_DEV__', false); + +/** + * Creates a prerender function whose self-fetch of the prerendered response + * resolves as specified, mimicking the production SSR path + * @param {() => Response | Promise} fetch_impl + */ +function setup(fetch_impl) { + const fn = vi.fn(() => 'from function'); + const wrapper = prerender(fn); + /** @type {any} */ (wrapper).__.id = 'hash/fn'; + + store.current = { + event: /** @type {RequestEvent} */ ( + /** @type {unknown} */ ({ + request: { url: 'http://localhost/' }, + isRemoteRequest: false, + cookies: {} + }) + ), + state: /** @type {RequestState} */ ( + /** @type {unknown} */ ({ + transport: {}, + remote: {}, + prerendering: undefined, + is_in_remote_query: false + }) + ) + }; + + vi.stubGlobal('fetch', vi.fn(fetch_impl)); + + return { fn, wrapper }; +} + +test('propagates an error response instead of running the function', async () => { + const { fn, wrapper } = setup( + () => + new Response(JSON.stringify({ type: 'error', error: { status: 418, message: 'teapot' } }), { + status: 200 + }) + ); + + const rejection = await wrapper().catch((e) => e); + + expect(rejection).toBeInstanceOf(HttpError); + expect(rejection.status).toBe(418); + expect(rejection.body).toEqual({ status: 418, message: 'teapot' }); + expect(fn).not.toHaveBeenCalled(); +}); + +test('parses a prerendered result without running the function', async () => { + const { fn, wrapper } = setup( + () => + new Response(JSON.stringify({ type: 'result', data: stringify({ _: 'prerendered' }, {}) }), { + status: 200 + }) + ); + + await expect(wrapper()).resolves.toBe('prerendered'); + expect(fn).not.toHaveBeenCalled(); +}); + +test('falls back to the function when the fetch rejects', async () => { + const { fn, wrapper } = setup(() => Promise.reject(new Error('connection refused'))); + + await expect(wrapper()).resolves.toBe('from function'); + expect(fn).toHaveBeenCalledOnce(); +}); + +test('falls back to the function on a non-ok response', async () => { + const { fn, wrapper } = setup(() => new Response('not found', { status: 404 })); + + await expect(wrapper()).resolves.toBe('from function'); + expect(fn).toHaveBeenCalledOnce(); +}); + +test('falls back to the function on a non-JSON response', async () => { + const { fn, wrapper } = setup(() => new Response('', { status: 200 })); + + await expect(wrapper()).resolves.toBe('from function'); + expect(fn).toHaveBeenCalledOnce(); +});