Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gentle-toys-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': major
---

breaking: change `form.error` type from `any` to `App.Error | undefined`
6 changes: 3 additions & 3 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ export type Transport = Record<string, Transporter>;
*/
export interface Transporter<
T = any,
U = Exclude<any, false | 0 | '' | null | undefined | typeof NaN>
U = any /* minus falsy values, but we can't properly express that */
> {
encode: (value: T) => false | U;
decode: (data: U) => T;
Expand Down Expand Up @@ -2292,8 +2292,8 @@ export type RemoteQueryUpdate =
| RemoteQueryOverride;

export type RemoteResource<T> = Promise<T> & {
/** The error in case the query fails. Most often this is a [`HttpError`](https://svelte.dev/docs/kit/@sveltejs-kit#HttpError) but it isn't guaranteed to be. */
get error(): any;
/** The error in case the query fails. */
get error(): App.Error | undefined;
/** `true` before the first result is available and during refreshes */
get loading(): boolean;
} & (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/** @import { Query } from './query/instance.svelte.js' */
/** @import { LiveQuery } from './query-live/instance.svelte.js' */
import { tick } from 'svelte';
import { once } from '../../../utils/functions.js';

/**
* @template R
* @template [R=Query<any> | LiveQuery<any>]
* @typedef {object} CacheEntry
* @property {number} proxy_count The number of live proxy instances referencing this
* entry. The entry is eligible for eviction when this hits zero.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
/* eslint-disable n/prefer-global/process */
import { describe, expect, test, vi } from 'vitest';
import { tick } from 'svelte';
import { HttpError } from '@sveltejs/kit/internal';

// Mock `client.js` because the real one pulls in the SvelteKit
// router/hydration machinery and resolves `$app/paths` to a server-side
// virtual module that only exists during a real SvelteKit build. We only need
// the cache `Map`s and a stub `app` for the instances' interactions.
vi.mock(new URL('../client.js', import.meta.url).pathname, () => ({
app: { hooks: { transport: {} }, decoders: {}, encoders: {} },
query_map: new Map(),
query_responses: {},
live_query_map: new Map(),
prerender_responses: {},
_goto: () => {}
}));
vi.mock(new URL('../client.js', import.meta.url).pathname, async () => {
const { HttpError } = await import('@sveltejs/kit/internal');
return {
app: { hooks: { transport: {} }, decoders: {}, encoders: {} },
query_map: new Map(),
query_responses: {},
live_query_map: new Map(),
prerender_responses: {},
_goto: () => {},
handle_error: (/** @type {any} */ error) =>
Promise.resolve(
error instanceof HttpError
? error.body
: { message: error?.message ?? String(error), status: 500 }
)
};
});

// `prerender.svelte.js` references the `__SVELTEKIT_DEV__` build-time constant at
// module scope; it isn't provided by the unit-test config, so define it here.
Expand Down Expand Up @@ -48,7 +58,7 @@ describe('reactive consumption never produces unhandled rejections', () => {
const q = new Query('id/payload', () => Promise.reject(new Error('nope')));
void q.current; // reactive read triggers start()
await flush();
expect(q.error).toBeInstanceOf(Error);
expect(q.error).toEqual({ message: 'nope', status: 500 });
expect(tracker.unhandled).toEqual([]);
} finally {
tracker.stop();
Expand All @@ -59,9 +69,9 @@ describe('reactive consumption never produces unhandled rejections', () => {
const tracker = track_unhandled();
try {
const instance = new LiveQuery('id', 'id/payload', 'payload');
instance.fail(new Error('nope'));
instance.fail(new HttpError(500, 'nope'));
await flush();
expect(instance.error).toBeInstanceOf(Error);
expect(instance.error).toEqual({ message: 'nope', status: 500 });
expect(tracker.unhandled).toEqual([]);
} finally {
tracker.stop();
Expand All @@ -76,7 +86,7 @@ describe('reactive consumption never produces unhandled rejections', () => {
const resource = prerender('id')(undefined);
void resource.current; // reactive read, no awaiting
await flush();
expect(resource.error).toBeInstanceOf(Error);
expect(resource.error).toEqual({ message: 'nope', status: 500 });
expect(tracker.unhandled).toEqual([]);
} finally {
globalThis.fetch = original_fetch;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
import { app_dir, base } from '$app/paths/internal/client';
import { version } from '$app/env';
import * as devalue from 'devalue';
import { app, _goto, prerender_responses } from '../client.js';
import { app, _goto, handle_error, prerender_responses } from '../client.js';
import { get_remote_request_headers, remote_request, unwrap_node } from './shared.svelte.js';
import { create_remote_key, stringify_remote_arg } from '../../shared.js';
import { noop } from '../../../utils/functions.js';
import { HttpError } from '@sveltejs/kit/internal';

// Initialize Cache API for prerender functions
const CACHE_NAME = __SVELTEKIT_DEV__ ? `sveltekit:${Date.now()}` : `sveltekit:${version}`;
Expand Down Expand Up @@ -148,6 +149,7 @@ class Prerender {
/** @type {T | undefined} */
#current = $state.raw();

/** @type {App.Error | undefined} */
#error = $state.raw(undefined);

/**
Expand All @@ -162,10 +164,15 @@ class Prerender {
this.#error = undefined;
return value;
},
(error) => {
async (e) => {
const error = await handle_error(e, {
params: {},
route: { id: null },
url: new URL(location.href)
});
this.#loading = false;
this.#error = error;
throw error;
throw new HttpError(error.status, error); // so that transformError doesn't transform it again
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { query_responses } from '../../client.js';
import { query_responses, handle_error } from '../../client.js';
import { HttpError, Redirect } from '@sveltejs/kit/internal';
import { noop, once } from '../../../../utils/functions.js';
import { SharedIterator } from '../../../../utils/shared-iterator.js';
Expand All @@ -24,7 +24,7 @@ export class LiveQuery {
#done = $state(false);
/** @type {T | undefined} */
#raw = $state.raw();
/** @type {any} */
/** @type {App.Error | undefined} */
#error = $state.raw(undefined);
/** @type {Promise<void>} */
#promise;
Expand Down Expand Up @@ -93,7 +93,7 @@ export class LiveQuery {
// and the query can recover
const error = new HttpError(node.e.status, node.e);
this.#loading = false;
this.#error = error;
this.#error = error.body;

promise.catch(noop);
this.#reject_first?.(error);
Expand Down Expand Up @@ -175,7 +175,7 @@ export class LiveQuery {

if (!this.#ready) {
// If we haven't successfully connected and received a value yet, surface the error
this.fail(error);
await this.#fail(error);
on_connect_failed(error);
break;
}
Expand Down Expand Up @@ -385,10 +385,10 @@ export class LiveQuery {
this.#fan_out.push(value);
}

/** @param {unknown} error */
/** @param {HttpError} error */
fail(error) {
this.#loading = false;
this.#error = error;
this.#error = error.body;
// `fail` is terminal — once a live query has hard-failed, the only way to start
// streaming again is via `reconnect()`. Mark it done and abort any in-flight
// request so that callers from outside the main loop (e.g. `apply_reconnections`)
Expand All @@ -410,6 +410,16 @@ export class LiveQuery {
this.#fan_out.fail(error);
}

/** @param {unknown} e */
async #fail(e) {
const error = await handle_error(e, {
params: {},
route: { id: null },
url: new URL(location.href)
});
this.fail(new HttpError(error.status, error));
}

get [Symbol.toStringTag]() {
return 'LiveQuery';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { query_responses } from '../../client.js';
import { query_responses, handle_error } from '../../client.js';
import { HttpError } from '@sveltejs/kit/internal';
import { QUERY_OVERRIDE_KEY } from '../shared.svelte.js';
import { noop } from '../../../../utils/functions.js';
Expand Down Expand Up @@ -37,7 +37,7 @@ export class Query {
return this.#overrides.reduce((v, r) => r(v), /** @type {T} */ (this.#raw));
});

/** @type {any} */
/** @type {App.Error | undefined} */
#error = $state.raw(undefined);

/** @type {Promise<T>['then']} */
Expand Down Expand Up @@ -126,22 +126,33 @@ export class Query {

resolve(undefined);
})
.catch((e) => {
.catch(async (e) => {
Comment thread
vercel[bot] marked this conversation as resolved.
// TODO: Our behavior here could be better:
// - We should not reject on redirects, but should hook into the router
// to ensure the query is properly refreshed before the navigation completes
// - Instead of failing on transport-level errors, we should probably do what
// LiveQuery does and preserve the last known good value and retry the connection
if (this.#latest.indexOf(resolve) === -1) return;

const error = await handle_error(e, {
params: {},
route: { id: null },
url: new URL(location.href)
});

// Re-check after the async `handle_error` gap: a later request may have
// resolved/rejected while we were awaiting and superseded this one, so
// recompute the index and bail out if this request is no longer current
const idx = this.#latest.indexOf(resolve);
if (idx === -1) return;

untrack(() => {
this.#latest.splice(0, idx).forEach((r) => r(undefined));
this.#error = e;
this.#error = error;
this.#loading = false;
});

reject(e);
reject(new HttpError(error.status, error)); // so that transformError doesn't transform it again
});

return promise;
Expand Down Expand Up @@ -229,17 +240,15 @@ export class Query {
this.#promise = Promise.resolve();
}

/**
* @param {unknown} error
*/
/** @param {HttpError} error */
fail(error) {
// normally consumed in the constructor, but make sure a leftover
// SSR record can never shadow the newly-set error
delete query_responses[this.#key];

this.#clear_pending();
this.#loading = false;
this.#error = error;
this.#error = error.body;

const promise = Promise.reject(error);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,8 @@ export async function remote_request(url, init) {
);

/**
*
* @param {string} key
* @param {CacheEntry<any> | undefined} entry
* @param {CacheEntry | undefined} entry
* @param {any} result
*/
function refresh(key, entry, result) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
const q = failing();
</script>

<div id="q-error">{q.error ? `${q.error.status}: ${q.error.body?.message}` : 'none'}</div>
<div id="q-error">
{q.error ? `${q.error.status}: ${q.error.message}` : 'none'}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@
{/snippet}
</svelte:boundary>

<div id="batch-error">{q.error ? `${q.error.status}: ${q.error.body?.message}` : 'none'}</div>
<div id="batch-error">
{q.error ? `${q.error.status}: ${q.error.message}` : 'none'}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

const { params } = $props();

const message = get_message(params.test_name);
const message = $derived(get_message(params.test_name));

const scoped = set_message.for(`scoped:${params.test_name}`);
const enhanced = set_message.for(`enhanced:${params.test_name}`);
const scoped = $derived(set_message.for(`scoped:${params.test_name}`));
const enhanced = $derived(set_message.for(`enhanced:${params.test_name}`));

let submit_result = $state('none');
let imperative_submit_result = $state('none');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
const q = $derived(live_fail(params.key));
</script>

<div id="live-error">{q.error ? `${q.error.status}: ${q.error.body.message}` : 'none'}</div>
<div id="live-error">
{q.error ? `${q.error.status}: ${q.error.message}` : 'none'}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
<button id="refresh-connections" onclick={refresh_connections}>refresh connections</button>

<p id="value">{live.current}</p>
<p id="error">{live.error ? `${live.error.status} ${live.error.body.message}` : ''}</p>
<p id="error">
{live.error ? `${live.error.status} ${live.error.message}` : ''}
</p>
<p id="connected">{String(live.connected)}</p>
<p id="done">{String(live.done)}</p>
<p id="connections">{connections}</p>
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
</script>

<div id="value">{q.current ?? 'unset'}</div>
<div id="error">{q.error ? `${q.error.status}: ${q.error.body.message}` : 'none'}</div>
<div id="error">
{q.error ? `${q.error.status}: ${q.error.message}` : 'none'}
</div>

<button onclick={() => bump(params.key).updates(q)}>bump</button>
6 changes: 3 additions & 3 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,7 @@ declare module '@sveltejs/kit' {
*/
export interface Transporter<
T = any,
U = Exclude<any, false | 0 | '' | null | undefined | typeof NaN>
U = any /* minus falsy values, but we can't properly express that */
> {
encode: (value: T) => false | U;
decode: (data: U) => T;
Expand Down Expand Up @@ -2265,8 +2265,8 @@ declare module '@sveltejs/kit' {
| RemoteQueryOverride;

export type RemoteResource<T> = Promise<T> & {
/** The error in case the query fails. Most often this is a [`HttpError`](https://svelte.dev/docs/kit/@sveltejs-kit#HttpError) but it isn't guaranteed to be. */
get error(): any;
/** The error in case the query fails. */
get error(): App.Error | undefined;
/** `true` before the first result is available and during refreshes */
get loading(): boolean;
} & (
Expand Down
Loading