From 4d766f87b9c2653203190a44f146daa13cb9a271 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 10 Oct 2025 18:01:42 -0600 Subject: [PATCH] checkpoint --- packages/svelte/package.json | 2 + .../reactivity/resources/create-fetcher.js | 66 +++++++++++ .../reactivity/resources/create-resource.js | 75 +++++++++++++ .../client/reactivity/resources}/resource.js | 32 +++--- .../svelte/src/reactivity/index-client.js | 4 +- .../svelte/src/reactivity/index-server.js | 106 ++++++++++++++++-- packages/svelte/types/index.d.ts | 24 +++- playgrounds/sandbox/ssr-dev.js | 2 +- pnpm-lock.yaml | 16 +++ 9 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js create mode 100644 packages/svelte/src/internal/client/reactivity/resources/create-resource.js rename packages/svelte/src/{reactivity => internal/client/reactivity/resources}/resource.js (85%) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d806aa09a5..5808876234 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -168,6 +168,7 @@ "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", @@ -180,6 +181,7 @@ "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", + "path-to-regexp": "^8.3.0", "zimmerframe": "^1.1.2" } } diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js new file mode 100644 index 0000000000..4e99300f1c --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js @@ -0,0 +1,66 @@ +/** @import { Resource } from './resource.js' */ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ + +import { compile } from 'path-to-regexp'; +import { create_resource } from './create-resource.js'; + +/** + * @template {Record} TPathParams + * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit + */ +/** + * @template TReturn + * @template {Record} TPathParams + * @typedef {(init: FetcherInit) => Resource} Fetcher + */ + +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? + * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {StandardSchemaV1} TSchema + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options + * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @template {StandardSchemaV1} TSchema + * @param {string} url + * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] + */ +export function create_fetcher(url, options) { + const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; + const populate_path = compile(raw_pathname); + /** + * @param {Parameters>[0]} args + * @returns {Promise} + */ + const fn = async (args) => { + const cloned_url = new URL(url); + const new_params = new URLSearchParams(args.searchParams); + const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); + cloned_url.search = combined_params.toString(); + cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API + // TODO how to populate path params + const resp = await fetch(cloned_url, args); + if (!resp.ok) { + throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (options?.schema) { + return options.schema['~standard'].validate(json); + } + return json; + }; + return create_resource(url.toString(), fn, options); +} diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-resource.js b/packages/svelte/src/internal/client/reactivity/resources/create-resource.js new file mode 100644 index 0000000000..1f849f9df3 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resources/create-resource.js @@ -0,0 +1,75 @@ +import { hydratable } from '../../context.js'; +import { tick } from '../../runtime'; +import { render_effect } from '../effects'; +import { Resource } from './resource.js'; + +/** @typedef {{ count: number, resource: Resource }} Entry */ +/** @type {Map} */ +const cache = new Map(); + +/** + * @template TReturn + * @template {unknown[]} [TArgs=[]] + * @template {typeof Resource} [TResource=typeof Resource] + * @param {string} name + * @param {(...args: TArgs) => Promise} fn + * @param {{ Resource?: TResource }} [options] + * @returns {(...args: TArgs) => Resource} + */ +export function create_resource(name, fn, options) { + const ResolvedResource = options?.Resource ?? Resource; + return (...args) => { + const stringified_args = 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))); + 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} cache + */ + return (entry, cache) => + tick().then(() => { + if (!entry?.count && entry === cache.get(key)) { + cache.delete(key); + } + }); +} diff --git a/packages/svelte/src/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resources/resource.js similarity index 85% rename from packages/svelte/src/reactivity/resource.js rename to packages/svelte/src/internal/client/reactivity/resources/resource.js index 4d423ec2b0..48e6a383ea 100644 --- a/packages/svelte/src/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/resource.js @@ -1,6 +1,6 @@ /** @import { Source, Derived } from '#client' */ -import { state, derived, set, get, tick } from 'svelte/internal/client'; -import { deferred, noop } from '../internal/shared/utils'; +import { state, derived, set, get, tick } from '../../index.js'; +import { deferred, noop } from '../../../shared/utils.js'; /** * @template T @@ -33,8 +33,6 @@ export class Resource { return get(this.#raw); }); - #onrefresh; - /** {@type Source} */ #error = state(undefined); @@ -58,19 +56,18 @@ export class Resource { /** * @param {() => Promise} fn - * @param {() => Promise} [init] - * @param {() => void} [onrefresh] */ - constructor(fn, init = fn, onrefresh = noop) { + constructor(fn) { this.#fn = fn; - this.#promise = state(this.#run(init)); - this.#onrefresh = onrefresh; + this.#promise = state(this.#run()); } - /** @param {() => Promise} fn */ - #run(fn = this.#fn) { + #run() { if (this.#init) { - set(this.#loading, true); + tick().then(() => { + // opt this out of async coordination + set(this.#loading, true); + }); } else { this.#init = true; } @@ -79,7 +76,7 @@ export class Resource { this.#latest.push(resolve); - Promise.resolve(fn()) + 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); @@ -152,21 +149,20 @@ export class Resource { /** * @returns {Promise} */ - refresh() { - this.#onrefresh(); + refresh = () => { const promise = this.#run(); set(this.#promise, promise); return promise; - } + }; /** * @param {T} value */ - set(value) { + set = (value) => { set(this.#ready, true); set(this.#loading, false); set(this.#error, undefined); set(this.#raw, value); set(this.#promise, Promise.resolve()); - } + }; } diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index beadbe9d10..42cd28658b 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,4 +5,6 @@ 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 './resource.js'; +export { Resource } from '../internal/client/reactivity/resources/resource.js'; +export { create_resource as createResource } from '../internal/client/reactivity/resources/create-resource.js'; +export { create_fetcher as createFetcher } from '../internal/client/reactivity/resources/create-fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 49c2f8597b..29da641110 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,3 +1,7 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +import { compile } from 'path-to-regexp'; +import { hydratable } from '../internal/server/context.js'; + export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; @@ -38,10 +42,9 @@ export class Resource { /** * @param {() => Promise} fn - * @param {() => Promise} [init] */ - constructor(fn, init = fn) { - this.#promise = Promise.resolve(init()).then( + constructor(fn) { + this.#promise = Promise.resolve(fn()).then( (val) => { this.#ready = true; this.#loading = false; @@ -94,18 +97,107 @@ export class Resource { return this.#ready; } - refresh() { + refresh = () => { throw new Error('TODO Cannot refresh a resource on the server'); - } + }; /** * @param {T} value */ - set(value) { + set = (value) => { this.#ready = true; this.#loading = false; this.#error = undefined; this.#current = value; this.#promise = Promise.resolve(); - } + }; +} + +/** @type {Map>} */ +// TODO scope to render, clear after render +const cache = new Map(); + +/** + * @template TReturn + * @template {unknown[]} [TArgs=[]] + * @template {typeof Resource} [TResource=typeof Resource] + * @param {string} name + * @param {(...args: TArgs) => Promise} fn + * @param {{ Resource?: TResource }} [options] + * @returns {(...args: TArgs) => Resource} + */ +export function createResource(name, fn, options) { + const ResolvedResource = options?.Resource ?? Resource; + return (...args) => { + const stringified_args = 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))); + cache.set(cache_key, resource); + return resource; + }; +} + +/** + * @template {Record} TPathParams + * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit + */ +/** + * @template TReturn + * @template {Record} TPathParams + * @typedef {(init: FetcherInit) => Resource} Fetcher + */ + +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? + * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {StandardSchemaV1} TSchema + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options + * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @template {StandardSchemaV1} TSchema + * @param {string} url + * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] + */ +export function createFetcher(url, options) { + const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; + const populate_path = compile(raw_pathname); + /** + * @param {Parameters>[0]} args + * @returns {Promise} + */ + const fn = async (args) => { + const cloned_url = new URL(url); + const new_params = new URLSearchParams(args.searchParams); + const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); + cloned_url.search = combined_params.toString(); + cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API + // TODO how to populate path params + const resp = await fetch(cloned_url, args); + if (!resp.ok) { + throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (options?.schema) { + return options.schema['~standard'].validate(json); + } + return json; + }; + return createResource(url.toString(), fn, options); } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 08875314ea..f0dee993ae 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2129,6 +2129,7 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { + import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * A reactive version of the built-in [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object. * Reading the date (whether with methods like `date.getTime()` or `date.toString()`, or via things like [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)) @@ -2394,7 +2395,7 @@ declare module 'svelte/reactivity' { export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export class Resource implements Partial> { - constructor(fn: () => Promise, init?: (() => Promise) | undefined); + constructor(fn: () => Promise); get then(): (onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined) => Promise; get catch(): (reject: any) => Promise; get finally(): (fn: any) => Promise; @@ -2409,11 +2410,28 @@ declare module 'svelte/reactivity' { */ get ready(): boolean; - refresh(): Promise; + refresh: () => Promise; - set(value: T): void; + set: (value: T) => void; #private; } + export function createResource(name: string, fn: (...args: TArgs) => Promise, options?: { + Resource?: TResource; + } | undefined): (...args: TArgs) => Resource; + export function createFetcher, TResource extends typeof Resource>(url: string, options?: { + Resource?: TResource; + schema?: undefined; + } | undefined): Fetcher | unknown[] | boolean | null, TPathParams>; + + export function createFetcher, TSchema extends StandardSchemaV1, TResource extends typeof Resource>(url: string, options: { + Resource?: TResource; + schema: StandardSchemaV1; + }): Fetcher, TPathParams>; + type FetcherInit> = { + searchParams?: ConstructorParameters[0]; + pathParams?: TPathParams; + } & RequestInit; + type Fetcher> = (init: FetcherInit) => Resource; class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 8a0c063d47..9486d2304e 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -27,7 +27,7 @@ polka() const { default: App } = await vite.ssrLoadModule('/src/App.svelte'); const { head, body } = await render(App); - + console.log(head); const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 973896f406..e58abc0696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@jridgewell/sourcemap-codec': specifier: ^1.5.0 version: 1.5.0 + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 '@sveltejs/acorn-typescript': specifier: ^1.0.5 version: 1.0.5(acorn@8.15.0) @@ -107,6 +110,9 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.17 + path-to-regexp: + specifier: ^8.3.0 + version: 8.3.0 zimmerframe: specifier: ^1.1.2 version: 1.1.2 @@ -825,6 +831,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stylistic/eslint-plugin-js@1.8.0': resolution: {integrity: sha512-jdvnzt+pZPg8TfclZlTZPiUbbima93ylvQ+wNgHLNmup3obY6heQvgewSu9i2CfS61BnRByv+F9fxQLPoNeHag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1921,6 +1930,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3102,6 +3114,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true + '@standard-schema/spec@1.0.0': {} + '@stylistic/eslint-plugin-js@1.8.0(eslint@9.9.1)': dependencies: '@types/eslint': 8.56.12 @@ -4283,6 +4297,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@1.1.2: {}