checkpoint; hydratable and base resource work

elliott/resources
Elliott Johnson 2 weeks ago
parent 30e2b23b59
commit da3260f99a

@ -174,6 +174,7 @@
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.3.2",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"is-reference": "^3.0.3",

@ -242,7 +242,13 @@ function init_update_callbacks(context) {
}
export { flushSync } from './internal/client/reactivity/batch.js';
export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js';
export {
getContext,
getAllContexts,
hasContext,
setContext,
hydratable
} from './internal/client/context.js';
export { hydrate, mount, unmount } from './internal/client/render.js';
export { tick, untrack, settled } from './internal/client/runtime.js';
export { createRawSnippet } from './internal/client/dom/blocks/snippet.js';

@ -39,6 +39,12 @@ export async function settled() {}
export { getAbortSignal } from './internal/server/abort-signal.js';
export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';
export {
getAllContexts,
getContext,
hasContext,
setContext,
hydratable
} from './internal/server/context.js';
export { createRawSnippet } from './internal/server/blocks/snippet.js';

@ -6,6 +6,7 @@ import { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
import { BRANCH_EFFECT, EFFECT_RAN } from './constants.js';
import { hydrating } from './dom/hydration.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@ -194,6 +195,30 @@ export function is_runes() {
return !legacy_mode_flag || (component_context !== null && component_context.l === null);
}
/**
* @template T
* @param {string} key
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
export function hydratable(key, fn) {
if (!hydrating) {
return fn();
}
/** @type {Map<string, unknown> | undefined} */
// @ts-expect-error
var store = window.__svelte?.h;
if (store === undefined) {
throw new Error('TODO this should be impossible?');
}
if (!store.has(key)) {
throw new Error(
`TODO Expected hydratable key "${key}" to exist during hydration, but it does not`
);
}
return /** @type {Promise<T>} */ (store.get(key));
}
/**
* @param {string} name
* @returns {Map<unknown, unknown>}

@ -110,3 +110,19 @@ export async function save(promise) {
return value;
};
}
/**
* @template T
* @param {string} key
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
export function hydratable(key, fn) {
if (ssr_context === null || ssr_context.r === null) {
// TODO probably should make this a different error like await_reactivity_loss
// also when can context be defined but r be null? just when context isn't used at all?
e.lifecycle_outside_component('hydratable');
}
return ssr_context.r.register_hydratable(key, fn);
}

@ -7,6 +7,7 @@ import * as e from './errors.js';
import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
import { uneval } from 'devalue';
/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -266,6 +267,25 @@ export class Renderer {
}
}
/**
* @template T
* @param {string} key
* @param {() => Promise<T>} fn
*/
register_hydratable(key, fn) {
if (this.global.mode === 'sync') {
// TODO
throw new Error('no no');
}
if (this.global.hydratables.has(key)) {
// TODO error
throw new Error("can't have two hydratables with the same key");
}
const result = fn();
this.global.hydratables.set(key, { blocking: true, value: result });
return result;
}
/**
* @param {() => void} fn
*/
@ -467,6 +487,7 @@ export class Renderer {
const renderer = Renderer.#open_render('async', component, options);
const content = await renderer.#collect_content_async();
content.head = (await renderer.#collect_hydratables()) + content.head;
return Renderer.#close_render(content, renderer);
} finally {
abort();
@ -511,6 +532,23 @@ export class Renderer {
return content;
}
async #collect_hydratables() {
const map = this.global.hydratables;
if (!map) return '';
// TODO add namespacing for multiple apps, nonce, csp, whatever -- not sure what we need to do there
/** @type {string} */
let resolved = '<script>(window.__svelte ??= {}).h = ';
let resolved_map = new Map();
for (const [k, v] of map) {
if (!v.blocking) continue;
// sequential await is okay here -- all the work is already kicked off
resolved_map.set(k, await v.value);
}
resolved += uneval(resolved_map) + '</script>';
return resolved;
}
/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
@ -576,6 +614,9 @@ export class SSRState {
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
/** @type {Map<string, { blocking: boolean, value: Promise<unknown> }>} */
hydratables = new Map();
/** @type {{ path: number[], value: string }} */
#title = { path: [], value: '' };

@ -5,3 +5,4 @@ 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';

@ -21,3 +21,91 @@ 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
* @param {() => Promise<T>} [init]
*/
constructor(fn, init = fn) {
this.#promise = Promise.resolve(init()).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();
}
}

@ -0,0 +1,172 @@
/** @import { Source, Derived } from '#client' */
import { state, derived, set, get, tick } from 'svelte/internal/client';
import { deferred, noop } from '../internal/shared/utils';
/**
* @template T
* @implements {Partial<Promise<T>>}
*/
export class Resource {
#init = false;
/** @type {() => Promise<T>} */
#fn;
/** @type {Source<boolean>} */
#loading = state(true);
/** @type {Array<(...args: any[]) => void>} */
#latest = [];
/** @type {Source<boolean>} */
#ready = state(false);
/** @type {Source<T | undefined>} */
#raw = state(undefined);
/** @type {Source<Promise<void>>} */
#promise;
/** @type {Derived<T | undefined>} */
#current = derived(() => {
if (!get(this.#ready)) return undefined;
return get(this.#raw);
});
#onrefresh;
/** {@type Source<any>} */
#error = state(undefined);
/** @type {Derived<Promise<T>['then']>} */
// @ts-expect-error - I feel this might actually be incorrect but I can't prove it yet.
// we are technically not returning a promise that resolves to the correct type... but it _is_ a promise that resolves at the correct time
#then = derived(() => {
const p = get(this.#promise);
return async (resolve, reject) => {
try {
await p;
await tick();
resolve?.(/** @type {T} */ (get(this.#current)));
} catch (error) {
reject?.(error);
}
};
});
/**
* @param {() => Promise<T>} fn
* @param {() => Promise<T>} [init]
* @param {() => void} [onrefresh]
*/
constructor(fn, init = fn, onrefresh = noop) {
this.#fn = fn;
this.#promise = state(this.#run(init));
this.#onrefresh = onrefresh;
}
/** @param {() => Promise<T>} fn */
#run(fn = this.#fn) {
if (this.#init) {
set(this.#loading, true);
} else {
this.#init = true;
}
const { resolve, reject, promise } = deferred();
this.#latest.push(resolve);
Promise.resolve(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;
this.#latest.splice(0, idx).forEach((r) => r());
set(this.#ready, true);
set(this.#loading, false);
set(this.#raw, value);
set(this.#error, undefined);
resolve(undefined);
})
.catch((e) => {
const idx = this.#latest.indexOf(resolve);
if (idx === -1) return;
this.#latest.splice(0, idx).forEach((r) => r());
set(this.#error, e);
set(this.#loading, false);
reject(e);
});
return promise;
}
get then() {
return get(this.#then);
}
get catch() {
get(this.#then);
return (/** @type {any} */ reject) => {
return get(this.#then)(undefined, reject);
};
}
get finally() {
get(this.#then);
return (/** @type {any} */ fn) => {
return get(this.#then)(
() => fn(),
() => fn()
);
};
}
get current() {
return get(this.#current);
}
get error() {
return get(this.#error);
}
/**
* Returns true if the resource is loading or reloading.
*/
get loading() {
return get(this.#loading);
}
/**
* Returns true once the resource has been loaded for the first time.
*/
get ready() {
return get(this.#ready);
}
/**
* @returns {Promise<void>}
*/
refresh() {
this.#onrefresh();
const promise = this.#run();
set(this.#promise, promise);
return promise;
}
/**
* @param {T} value
*/
set(value) {
set(this.#ready, true);
set(this.#loading, false);
set(this.#error, undefined);
set(this.#raw, value);
set(this.#promise, Promise.resolve());
}
}

@ -476,6 +476,8 @@ declare module 'svelte' {
*
* */
export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T;
export function hydratable<T>(key: string, fn: () => Promise<T>): Promise<T>;
/**
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component.
* Transitions will play during the initial render unless the `intro` option is set to `false`.
@ -2390,6 +2392,28 @@ declare module 'svelte/reactivity' {
* @since 5.7.0
*/
export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void;
export class Resource<T> implements Partial<Promise<T>> {
constructor(fn: () => Promise<T>, init?: (() => Promise<T>) | undefined);
get then(): <TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined) => Promise<TResult1 | TResult2>;
get catch(): (reject: any) => Promise<T>;
get finally(): (fn: any) => Promise<any>;
get current(): T | undefined;
get error(): undefined;
/**
* Returns true if the resource is loading or reloading.
*/
get loading(): boolean;
/**
* Returns true once the resource has been loaded for the first time.
*/
get ready(): boolean;
refresh(): Promise<void>;
set(value: T): void;
#private;
}
class ReactiveValue<T> {
constructor(fn: () => T, onsubscribe: (update: () => void) => void);

@ -89,6 +89,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
devalue:
specifier: ^5.3.2
version: 5.3.2
esm-env:
specifier: ^1.2.1
version: 1.2.1
@ -1236,6 +1239,9 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
devalue@5.3.2:
resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -3546,6 +3552,8 @@ snapshots:
detect-libc@1.0.3:
optional: true
devalue@5.3.2: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0

Loading…
Cancel
Save