@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
513 lines (425 loc) • 12.2 kB
JavaScript
/** @import { RemoteQueryFunction } from '@sveltejs/kit' */
import { app_dir, base } from '$app/paths/internal/client';
import { app, query_map, query_responses } from '../client.js';
import {
get_remote_request_headers,
is_in_effect,
QUERY_FUNCTION_ID,
QUERY_OVERRIDE_KEY,
QUERY_RESOURCE_KEY,
remote_request
} from './shared.svelte.js';
import * as devalue from 'devalue';
import { DEV } from 'esm-env';
import { noop } from '../../../utils/functions.js';
import { with_resolvers } from '../../../utils/promise.js';
import { tick, untrack } from 'svelte';
import { create_remote_key, stringify_remote_arg, unfriendly_hydratable } from '../../shared.js';
/**
* @template T
* @typedef {{
* count: number;
* resource: Query<T>;
* cleanup: () => void;
* }} RemoteQueryCacheEntry
*/
/**
* @param {string} id
* @returns {RemoteQueryFunction<any, any>}
*/
export function query(id) {
if (DEV) {
// If this reruns as part of HMR, refresh the query
const entries = query_map.get(id);
if (entries) {
for (const entry of entries.values()) {
void entry.resource.refresh();
}
}
}
/** @type {RemoteQueryFunction<any, any>} */
const wrapper = (arg) => {
return new QueryProxy(id, arg, async (key, payload) => {
const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`;
const serialized = await unfriendly_hydratable(key, () =>
remote_request(url, get_remote_request_headers())
);
return devalue.parse(serialized, app.decoders);
});
};
Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id });
return wrapper;
}
/**
* The actual query instance. There should only ever be one active query instance per key.
*
* @template T
* @implements {Promise<T>}
*/
export class Query {
/** @type {string} */
#key;
/** @type {() => Promise<T>} */
#fn;
#loading = $state(true);
/** @type {Array<(value: undefined) => void>} */
#latest = [];
/** @type {boolean} */
#ready = $state(false);
/** @type {T | undefined} */
#raw = $state.raw();
/** @type {Promise<void> | null} */
#promise = $state.raw(null);
/** @type {Array<(old: T) => T>} */
#overrides = $state([]);
/** @type {T | undefined} */
#current = $derived.by(() => {
// don't reduce undefined value
if (!this.#ready) return undefined;
return this.#overrides.reduce((v, r) => r(v), /** @type {T} */ (this.#raw));
});
/** @type {any} */
#error = $state.raw(undefined);
/** @type {Promise<T>['then']} */
// @ts-expect-error TS doesn't understand that the promise returns something
#then = $derived.by(() => {
const p = this.#get_promise();
this.#overrides.length;
return (resolve, reject) => {
const result = p.then(tick).then(() => /** @type {T} */ (this.#current));
if (resolve || reject) {
return result.then(resolve, reject);
}
return result;
};
});
/**
* @param {string} key
* @param {() => Promise<T>} fn
*/
constructor(key, fn) {
this.#key = key;
this.#fn = fn;
}
#get_promise() {
void untrack(() => (this.#promise ??= this.#run()));
return /** @type {Promise<T>} */ (this.#promise);
}
#start() {
// there is a really weird bug with untrack and writes and initializations
// every time you see this comment, try removing the `tick.then` here and see
// if all the tests still pass with the latest svelte version
// if they do, congrats, you can remove tick.then
void tick().then(() => this.#get_promise());
}
#clear_pending() {
this.#latest.forEach((r) => r(undefined));
this.#latest.length = 0;
}
#run() {
this.#loading = true;
const { promise, resolve, reject } = with_resolvers();
this.#latest.push(resolve);
Promise.resolve(this.#fn())
.then((value) => {
// Skip the response if resource was refreshed with a later promise while we were waiting for this one to resolve
const idx = this.#latest.indexOf(resolve);
if (idx === -1) return;
// Untrack this to not trigger mutation validation errors which can occur if you do e.g. $derived({ a: await queryA(), b: await queryB() })
untrack(() => {
this.#latest.splice(0, idx).forEach((r) => r(undefined));
this.#ready = true;
this.#loading = false;
this.#raw = value;
this.#error = undefined;
});
resolve(undefined);
})
.catch((e) => {
// 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
const idx = this.#latest.indexOf(resolve);
if (idx === -1) return;
untrack(() => {
this.#latest.splice(0, idx).forEach((r) => r(undefined));
this.#error = e;
this.#loading = false;
});
reject(e);
});
return promise;
}
get then() {
// TODO this should be unnecessary but due to the bug described
// in #start, we need to do this in some circumstances
this.#start();
return this.#then;
}
get catch() {
this.#start();
this.#then;
return (/** @type {any} */ reject) => {
return this.#then(undefined, reject);
};
}
get finally() {
this.#start();
this.#then;
return (/** @type {any} */ fn) => {
return this.#then(
(value) => {
fn();
return value;
},
(error) => {
fn();
throw error;
}
);
};
}
get current() {
this.#start();
return this.#current;
}
get error() {
this.#start();
return this.#error;
}
/**
* Returns true if the resource is loading or reloading.
*/
get loading() {
this.#start();
return this.#loading;
}
/**
* Returns true once the resource has been loaded for the first time.
*/
get ready() {
this.#start();
return this.#ready;
}
/**
* @returns {Promise<void>}
*/
refresh() {
delete query_responses[this.#key];
return (this.#promise = this.#run());
}
/**
* @param {T} value
*/
set(value) {
this.#clear_pending();
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#raw = value;
this.#promise = Promise.resolve();
}
/**
* @param {unknown} error
*/
fail(error) {
this.#clear_pending();
this.#loading = false;
this.#error = error;
const promise = Promise.reject(error);
promise.catch(noop);
this.#promise = promise;
}
/**
* @param {(old: T) => T} fn
* @returns {(() => void) & { [QUERY_OVERRIDE_KEY]: string }}
*/
withOverride(fn) {
this.#overrides.push(fn);
const release = /** @type {(() => void) & { [QUERY_OVERRIDE_KEY]: string }} */ (
() => {
const i = this.#overrides.indexOf(fn);
if (i !== -1) {
this.#overrides.splice(i, 1);
}
}
);
Object.defineProperty(release, QUERY_OVERRIDE_KEY, { value: this.#key });
return release;
}
get [Symbol.toStringTag]() {
return 'Query';
}
}
/**
* Manages the caching layer between the user and the actual {@link Query} instance. This is the thing
* the developer actually gets to interact with in their application code.
*
* @template T
* @implements {Promise<T>}
*/
export class QueryProxy {
#id;
#key;
#payload;
#fn;
#active = true;
/**
* Whether this proxy was created in a tracking context.
* @readonly
*/
#tracking = is_in_effect();
/**
* @param {string} id
* @param {any} arg
* @param {(key: string, payload: string) => Promise<T>} fn
*/
constructor(id, arg, fn) {
this.#id = id;
this.#payload = stringify_remote_arg(arg, app.hooks.transport);
this.#key = create_remote_key(id, this.#payload);
Object.defineProperty(this, QUERY_RESOURCE_KEY, { value: this.#key });
this.#fn = fn;
if (!this.#tracking) {
this.#active = false;
return;
}
const entry = this.#get_or_create_cache_entry();
$effect.pre(() => () => {
const die = this.#release(entry);
void tick().then(die);
});
}
/** @returns {RemoteQueryCacheEntry<T>} */
#get_or_create_cache_entry() {
let query_instances = query_map.get(this.#id);
if (!query_instances) {
query_instances = new Map();
query_map.set(this.#id, query_instances);
}
let this_instance = query_instances.get(this.#payload);
if (!this_instance) {
const c = (this_instance = {
count: 0,
resource: /** @type {Query<T>} */ (/** @type {unknown} */ (null)),
cleanup: /** @type {() => void} */ (/** @type {unknown} */ (null))
});
c.cleanup = $effect.root(() => {
c.resource = new Query(this.#key, () => this.#fn(this.#key, this.#payload));
});
query_instances.set(this.#payload, this_instance);
}
this_instance.count += 1;
return this_instance;
}
/**
* @param {RemoteQueryCacheEntry<T>} entry
* @param {boolean} [deactivate]
* @returns
*/
#release(entry, deactivate = true) {
this.#active &&= !deactivate;
entry.count -= 1;
return () => {
const query_instances = query_map.get(this.#id);
const this_instance = query_instances?.get(this.#payload);
if (this_instance?.count === 0) {
this_instance.cleanup();
query_instances?.delete(this.#payload);
}
if (query_instances?.size === 0) {
query_map.delete(this.#id);
}
};
}
#safe_get_cached_query() {
return query_map.get(this.#id)?.get(this.#payload)?.resource;
}
get current() {
return this.#safe_get_cached_query()?.current;
}
get error() {
return this.#safe_get_cached_query()?.error;
}
get loading() {
return this.#safe_get_cached_query()?.loading ?? false;
}
get ready() {
return this.#safe_get_cached_query()?.ready ?? false;
}
run() {
if (is_in_effect()) {
throw new Error(
'On the client, .run() can only be called outside render, e.g. in universal `load` functions and event handlers. In render, await the query directly'
);
}
if (Object.hasOwn(query_responses, this.#key)) {
return Promise.resolve(query_responses[this.#key]);
}
return this.#fn(this.#key, this.#payload);
}
refresh() {
return this.#safe_get_cached_query()?.refresh() ?? Promise.resolve();
}
/** @type {Query<T>['set']} */
set(value) {
this.#safe_get_cached_query()?.set(value);
}
/** @type {Query<T>['withOverride']} */
withOverride(fn) {
const entry = this.#get_or_create_cache_entry();
const override = /** @type {Query<T>} */ (entry.resource).withOverride(fn);
const release = /** @type {(() => void) & { [QUERY_OVERRIDE_KEY]: string }} */ (
() => {
override();
this.#release(entry, false)();
}
);
Object.defineProperty(release, QUERY_OVERRIDE_KEY, { value: override[QUERY_OVERRIDE_KEY] });
return release;
}
#get_cached_query() {
// TODO iterate on error messages
if (!this.#tracking) {
throw new Error(
'This query was not created in a reactive context and cannot be awaited. Use `.run()` to execute the query instead.'
);
}
if (!this.#active) {
throw new Error(
'This query instance is no longer active and can no longer be awaited. ' +
'This typically means you created the query in a tracking context and stashed it somewhere outside of a tracking context.'
);
}
const cached = query_map.get(this.#id)?.get(this.#payload);
if (!cached) {
// The only case where `this.#active` can be `true` is when we've added an entry to `query_map`, and the
// only way that entry can get removed is if this instance (and all others) have been deactivated.
// So if we get here, someone (us, check git blame and point fingers) did `entry.count -= 1` improperly.
throw new Error(
'No cached query found. This should be impossible. Please file a bug report.'
);
}
return cached.resource;
}
/** @type {Query<T>['then']} */
get then() {
const cached = this.#get_cached_query();
return cached.then.bind(cached);
}
/** @type {Query<T>['catch']} */
get catch() {
const cached = this.#get_cached_query();
return cached.catch.bind(cached);
}
/** @type {Query<T>['finally']} */
get finally() {
const cached = this.#get_cached_query();
return cached.finally.bind(cached);
}
get [Symbol.toStringTag]() {
return 'QueryProxy';
}
}