making progress i think

pull/17124/head
Elliott Johnson 1 month ago
parent 5de63834dc
commit 8449ea76d8

@ -228,7 +228,7 @@ export function is_runes() {
* @template T
* @param {string} key
* @param {() => T} fn
* @param {{ transport?: Transport }} [options]
* @param {{ transport?: Transport<T> }} [options]
* @returns {Promise<T>}
*/
export function hydratable(key, fn, { transport } = {}) {

@ -0,0 +1,134 @@
import { tick } from '../runtime.js';
import { render_effect } from './effects.js';
/** @typedef {{ count: number, item: any }} Entry */
/** @type {Map<string, Entry>} */
const client_cache = new Map();
/**
* @template TReturn
* @template {unknown} TArg
* @param {string} name
* @param {(arg: TArg, key: string) => TReturn} fn
* @param {{ hash?: (arg: TArg) => string }} [options]
* @returns {(arg: TArg) => TReturn}
*/
export function cache(name, fn, { hash = default_hash } = {}) {
return (arg) => {
const key = `${name}::::${hash(arg)}`;
const cached = client_cache.has(key);
const entry = client_cache.get(key);
const maybe_remove = create_remover(key);
let tracking = true;
try {
render_effect(() => {
if (entry) entry.count++;
return () => {
const entry = client_cache.get(key);
if (!entry) return;
entry.count--;
maybe_remove(entry);
};
});
} catch {
tracking = false;
}
if (cached) {
return entry?.item;
}
const item = fn(arg, key);
const new_entry = {
item,
count: tracking ? 1 : 0
};
client_cache.set(key, new_entry);
Promise.resolve(item).then(
() => maybe_remove(new_entry),
() => maybe_remove(new_entry)
);
return item;
};
}
/**
* @param {string} key
*/
function create_remover(key) {
/**
* @param {Entry | undefined} entry
*/
return (entry) =>
tick().then(() => {
if (!entry?.count && entry === client_cache.get(key)) {
client_cache.delete(key);
}
});
}
/** @implements {ReadonlyMap<string, any>} */
class ReadonlyCache {
/** @type {ReadonlyMap<string, any>['get']} */
get(key) {
const entry = client_cache.get(key);
return entry?.item;
}
/** @type {ReadonlyMap<string, any>['has']} */
has(key) {
return client_cache.has(key);
}
/** @type {ReadonlyMap<string, any>['size']} */
get size() {
return client_cache.size;
}
/** @type {ReadonlyMap<string, any>['forEach']} */
forEach(cb) {
client_cache.forEach((entry, key) => cb(entry.item, key, this));
}
/** @type {ReadonlyMap<string, any>['entries']} */
*entries() {
for (const [key, entry] of client_cache.entries()) {
yield [key, entry.item];
}
}
/** @type {ReadonlyMap<string, any>['keys']} */
*keys() {
for (const key of client_cache.keys()) {
yield key;
}
}
/** @type {ReadonlyMap<string, any>['values']} */
*values() {
for (const entry of client_cache.values()) {
yield entry.item;
}
}
[Symbol.iterator]() {
return this.entries();
}
}
const readonly_cache = new ReadonlyCache();
/** @returns {ReadonlyMap<string, any>} */
export function get_cache() {
return readonly_cache;
}
/**
* @param {...any} args
* @returns
*/
function default_hash(...args) {
return JSON.stringify(args);
}

@ -0,0 +1,44 @@
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
import { cache } from './cache';
/**
* @template {StandardSchemaV1} TSchema
* @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} args
* @param {string} [key]
*/
async function fetcher_impl({ schema, url, init }, key) {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`Fetch error: ${response.status} ${response.statusText}`);
}
if (schema) {
const data = await response.json();
return schema['~standard'].validate(data);
}
return response.json();
}
const cached_fetch = cache('svelte/fetcher', fetcher_impl, {
hash: (arg) => {
return `${typeof arg.url === 'string' ? arg.url : arg.url.toString()}}`;
}
});
/**
* @template {StandardSchemaV1} TSchema
* @overload
* @param {{ schema: TSchema, url: string | URL, init?: RequestInit }} arg
* @returns {Promise<StandardSchemaV1.InferOutput<TSchema>>}
*/
/**
* @overload
* @param {{ schema?: undefined, url: string | URL, init?: RequestInit }} arg
* @returns {Promise<any>}
*/
/**
* @template {StandardSchemaV1} TSchema
* @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} arg
*/
export function fetcher(arg) {
return cached_fetch(arg);
}

@ -1,12 +1,22 @@
/** @import { Source, Derived } from '#client' */
import { state, derived, set, get, tick } from '../../index.js';
import { deferred } from '../../../shared/utils.js';
/** @import { Resource as ResourceType } from '#shared' */
import { state, derived, set, get, tick } from '../index.js';
import { deferred } from '../../shared/utils.js';
/**
* @template T
* @param {() => Promise<T>} fn
* @returns {ResourceType<T>}
*/
export function resource(fn) {
return /** @type {ResourceType<T>} */ (/** @type {unknown} */ (new Resource(fn)));
}
/**
* @template T
* @implements {Partial<Promise<T>>}
*/
export class Resource {
class Resource {
#init = false;
/** @type {() => Promise<T>} */

@ -1,78 +0,0 @@
/** @import { Transport } from '#shared' */
import { hydratable } from '../../context.js';
import { tick } from '../../runtime.js';
import { render_effect } from '../effects.js';
import { Resource } from './resource.js';
/** @typedef {{ count: number, resource: Resource<any> }} Entry */
/** @type {Map<string, Entry>} */
const cache = new Map();
/**
* @template TReturn
* @template {unknown[]} [TArgs=[]]
* @template {typeof Resource} [TResource=typeof Resource]
* @param {string} name
* @param {(...args: TArgs) => TReturn} fn
* @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options]
* @returns {(...args: TArgs) => Resource<TReturn>}
*/
export function define_resource(name, fn, options = {}) {
const ResolvedResource = options?.Resource ?? Resource;
return (...args) => {
const stringified_args = (options.hash ?? JSON.stringify)(args);
const cache_key = `${name}:${stringified_args}`;
let entry = cache.get(cache_key);
const maybe_remove = create_remover(cache_key);
let tracking = true;
try {
render_effect(() => {
if (entry) entry.count++;
return () => {
const entry = cache.get(cache_key);
if (!entry) return;
entry.count--;
maybe_remove(entry, cache);
};
});
} catch {
tracking = false;
}
let resource = entry?.resource;
if (!resource) {
resource = new ResolvedResource(() =>
hydratable(cache_key, () => fn(...args), { transport: options.transport })
);
const entry = {
resource,
count: tracking ? 1 : 0
};
cache.set(cache_key, entry);
resource.then(
() => maybe_remove(entry, cache),
() => maybe_remove(entry, cache)
);
}
return resource;
};
}
/**
* @param {string} key
*/
function create_remover(key) {
/**
* @param {Entry | undefined} entry
* @param {Map<string, Entry>} cache
*/
return (entry, cache) =>
tick().then(() => {
if (!entry?.count && entry === cache.get(key)) {
cache.delete(key);
}
});
}

@ -129,7 +129,7 @@ export async function save(promise) {
* @template T
* @param {string} key
* @param {() => T} fn
* @param {{ transport?: Transport }} [options]
* @param {{ transport?: Transport<T> }} [options]
* @returns {Promise<T>}
*/
export function hydratable(key, fn, { transport } = {}) {

@ -0,0 +1,35 @@
import { get_render_store } from '../context';
/**
* @template TReturn
* @template {unknown} TArg
* @param {string} name
* @param {(arg: TArg, key: string) => TReturn} fn
* @param {{ hash?: (arg: TArg) => string }} [options]
* @returns {(arg: TArg) => TReturn}
*/
export function cache(name, fn, { hash = default_hash } = {}) {
return (arg) => {
const cache = get_render_store().cache;
const key = `${name}::::${hash(arg)}`;
const entry = cache.get(key);
if (entry) {
return /** @type {TReturn} */ (entry);
}
const new_entry = fn(arg, key);
cache.set(key, new_entry);
return new_entry;
};
}
/**
* @param {any} arg
* @returns {string}
*/
function default_hash(arg) {
return JSON.stringify(arg);
}
export function get_cache() {
throw new Error('TODO: cannot get cache on the server');
}

@ -0,0 +1,97 @@
/** @import { Resource as ResourceType } from '#shared' */
/**
* @template T
* @param {() => Promise<T>} fn
* @returns {ResourceType<T>}
*/
export function resource(fn) {
return /** @type {ResourceType<T>} */ (/** @type {unknown} */ (new Resource(fn)));
}
/**
* @template T
* @implements {Partial<Promise<T>>}
*/
class Resource {
/** @type {Promise<void>} */
#promise;
#ready = false;
#loading = true;
/** @type {T | undefined} */
#current = undefined;
#error = undefined;
/**
* @param {() => Promise<T>} fn
*/
constructor(fn) {
this.#promise = Promise.resolve(fn()).then(
(val) => {
this.#ready = true;
this.#loading = false;
this.#current = val;
this.#error = undefined;
},
(error) => {
this.#error = error;
this.#loading = false;
}
);
}
get then() {
// @ts-expect-error
return (onfulfilled, onrejected) =>
this.#promise.then(
() => onfulfilled?.(this.#current),
() => onrejected?.(this.#error)
);
}
get catch() {
return (/** @type {any} */ onrejected) => this.then(undefined, onrejected);
}
get finally() {
return (/** @type {any} */ onfinally) => this.then(onfinally, onfinally);
}
get current() {
return this.#current;
}
get error() {
return this.#error;
}
/**
* Returns true if the resource is loading or reloading.
*/
get loading() {
return this.#loading;
}
/**
* Returns true once the resource has been loaded for the first time.
*/
get ready() {
return this.#ready;
}
refresh = () => {
throw new Error('TODO Cannot refresh a resource on the server');
};
/**
* @param {T} value
*/
set = (value) => {
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#current = value;
this.#promise = Promise.resolve();
};
}

@ -1,5 +1,4 @@
import type { MaybePromise, Transport } from '#shared';
import type { Resource } from '../../reactivity/index-server';
import type { Element } from './dev';
import type { Renderer } from './renderer';
@ -21,10 +20,10 @@ export interface ALSContext {
string,
{
value: MaybePromise<unknown>;
transport: Transport | undefined;
transport: Transport<any> | undefined;
}
>;
resources: Map<string, Resource<any>>;
cache: Map<string, unknown>;
}
export interface SyncRenderOutput {

@ -11,7 +11,27 @@ export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>;
export type Transport = {
stringify: (value: unknown) => string;
parse: (value: string) => unknown;
export type Transport<T> = {
stringify: (value: T) => string;
parse: (value: string) => T;
};
export type Resource<T> = {
then: Promise<T>['then'];
catch: Promise<T>['catch'];
finally: Promise<T>['finally'];
refresh: () => Promise<void>;
set: (value: T) => void;
loading: boolean;
} & (
| {
ready: false;
value: undefined;
error: undefined;
}
| {
ready: true;
value: T;
error: any;
}
);

@ -5,5 +5,5 @@ export { SvelteURL } from './url.js';
export { SvelteURLSearchParams } from './url-search-params.js';
export { MediaQuery } from './media-query.js';
export { createSubscriber } from './create-subscriber.js';
export { Resource } from '../internal/client/reactivity/resources/resource.js';
export { define_resource as defineResource } from '../internal/client/reactivity/resources/define-resource.js';
export { resource } from '../internal/client/reactivity/resource.js';
export { cache, get_cache as getCache } from '../internal/client/reactivity/cache.js';

@ -1,7 +1,5 @@
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
/** @import { Transport } from '#shared' */
import { uneval } from 'devalue';
import { get_render_store, hydratable } from '../internal/server/context.js';
export { resource } from '../internal/server/reactivity/resource.js';
export { cache, get_cache as getCache } from '../internal/server/reactivity/cache.js';
export const SvelteDate = globalThis.Date;
export const SvelteSet = globalThis.Set;
@ -26,117 +24,3 @@ export class MediaQuery {
export function createSubscriber(_) {
return () => {};
}
/**
* @template T
* @implements {Partial<Promise<T>>}
*/
export class Resource {
/** @type {Promise<void>} */
#promise;
#ready = false;
#loading = true;
/** @type {T | undefined} */
#current = undefined;
#error = undefined;
/**
* @param {() => Promise<T>} fn
*/
constructor(fn) {
this.#promise = Promise.resolve(fn()).then(
(val) => {
this.#ready = true;
this.#loading = false;
this.#current = val;
this.#error = undefined;
},
(error) => {
this.#error = error;
this.#loading = false;
}
);
}
get then() {
// @ts-expect-error
return (onfulfilled, onrejected) =>
this.#promise.then(
() => onfulfilled?.(this.#current),
() => onrejected?.(this.#error)
);
}
get catch() {
return (/** @type {any} */ onrejected) => this.then(undefined, onrejected);
}
get finally() {
return (/** @type {any} */ onfinally) => this.then(onfinally, onfinally);
}
get current() {
return this.#current;
}
get error() {
return this.#error;
}
/**
* Returns true if the resource is loading or reloading.
*/
get loading() {
return this.#loading;
}
/**
* Returns true once the resource has been loaded for the first time.
*/
get ready() {
return this.#ready;
}
refresh = () => {
throw new Error('TODO Cannot refresh a resource on the server');
};
/**
* @param {T} value
*/
set = (value) => {
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#current = value;
this.#promise = Promise.resolve();
};
}
/**
* @template TReturn
* @template {unknown[]} [TArgs=[]]
* @template {typeof Resource} [TResource=typeof Resource]
* @param {string} name
* @param {(...args: TArgs) => TReturn} fn
* @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options]
* @returns {(...args: TArgs) => Resource<TReturn>}
*/
export function defineResource(name, fn, options = {}) {
const ResolvedResource = options?.Resource ?? Resource;
return (...args) => {
const cache = get_render_store().resources;
const stringified_args = (options.hash ?? JSON.stringify)(args);
const cache_key = `${name}:${stringified_args}`;
const entry = cache.get(cache_key);
if (entry) {
return entry;
}
const resource = new ResolvedResource(() =>
hydratable(cache_key, () => fn(...args), { transport: options.transport })
);
cache.set(cache_key, resource);
return resource;
};
}

Loading…
Cancel
Save