reactive cache

elliott/resources
Elliott Johnson 2 weeks ago
parent 0ff96567a3
commit 141fd1e955

@ -1,83 +1,60 @@
/** @import { CacheEntry } from '#shared' */
import { async_mode_flag } from '../../flags/index.js';
import { BaseCacheObserver } from '../../shared/cache-observer.js';
import { tick } from '../runtime.js';
import { get_effect_validation_error_code, render_effect } from './effects.js';
import { active_effect, is_destroying_effect, tick } from '../runtime.js';
import { render_effect } from './effects.js';
import * as e from '../errors.js';
/** @typedef {{ count: number, item: any }} Entry */
/** @type {Map<string, CacheEntry>} */
const client_cache = new Map();
/** @template T */
export class ReactiveCache {
/** @type {Map<string, CacheEntry<T>>} */
#cache = new Map();
/**
* @template {(...args: any[]) => any} TFn
* @param {string} key
* @param {TFn} fn
* @returns {ReturnType<TFn>}
*/
export function cache(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('cache');
}
const entry = client_cache.get(key);
const maybe_remove = create_remover(key);
const tracking = get_effect_validation_error_code() === null;
if (tracking) {
render_effect(() => {
if (entry) entry.count++;
return () => {
const entry = client_cache.get(key);
if (!entry) return;
entry.count--;
maybe_remove(entry);
};
});
constructor() {
if (!async_mode_flag) {
e.experimental_async_required('ReactiveCache');
}
}
if (entry !== undefined) {
return entry?.item;
}
/**
* @param {string} key
* @param {() => T} fn
* @returns {T}
*/
register(key, fn) {
let entry = this.#cache.get(key);
const item = fn();
const new_entry = {
item,
count: tracking ? 1 : 0
};
client_cache.set(key, new_entry);
if (!entry) {
entry = { count: 0, item: fn() };
this.#cache.set(key, entry);
}
Promise.resolve(item).then(
() => maybe_remove(new_entry),
() => maybe_remove(new_entry)
);
return item;
}
const maybe_remove = () => {
tick().then(() => {
if (entry.count === 0 && this.#cache.get(key) === entry) {
this.#cache.delete(key);
}
});
};
if (active_effect !== null && !is_destroying_effect) {
render_effect(() => {
entry.count++;
return () => {
entry.count--;
maybe_remove();
};
});
} else {
throw new Error('TODO must be called from within a reactive context');
}
/**
* @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);
}
});
}
return entry.item;
}
/**
* @template T
* @extends BaseCacheObserver<T>
*/
export class CacheObserver extends BaseCacheObserver {
constructor(prefix = '') {
if (!async_mode_flag) {
e.experimental_async_required('CacheObserver');
*[Symbol.iterator]() {
for (const entry of this.#cache.values()) {
yield entry.item;
}
super(() => client_cache, prefix);
}
}

@ -1,11 +1,13 @@
/** @import { GetRequestInit, Resource } from '#shared' */
import { cache } from './cache';
import { ReactiveCache } from './cache';
import { fetch_json } from '../../shared/utils.js';
import { hydratable } from '../hydratable';
import { resource } from './resource';
import { async_mode_flag } from '../../flags';
import * as e from '../errors.js';
const fetch_cache = new ReactiveCache();
/**
* @template TReturn
* @param {string | URL} url
@ -17,6 +19,8 @@ export function fetcher(url, init) {
e.experimental_async_required('fetcher');
}
const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`;
return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init))));
const key = `svelte/fetcher/${url}`;
return fetch_cache.register(key, () =>
resource(() => hydratable(key, () => fetch_json(url, init)))
);
}

@ -1,38 +1,44 @@
import { async_mode_flag } from '../../flags/index.js';
import { BaseCacheObserver } from '../../shared/cache-observer.js';
import { get_render_context } from '../render-context.js';
import * as e from '../errors.js';
import { get_render_context } from '../render-context.js';
/** @template T */
export class ReactiveCache {
#key = Symbol('ReactiveCache');
constructor() {
if (!async_mode_flag) {
e.experimental_async_required('ReactiveCache');
}
}
/**
* @param {string} key
* @param {() => T} fn
* @returns {T}
*/
register(key, fn) {
const cache = this.#get_cache();
let entry = cache.get(key);
if (!entry) {
entry = fn();
cache.set(key, entry);
}
/**
* @template {(...args: any[]) => any} TFn
* @param {string} key
* @param {TFn} fn
* @returns {ReturnType<TFn>}
*/
export function cache(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('cache');
return entry;
}
const cache = get_render_context().cache;
const entry = cache.get(key);
if (entry) {
return /** @type {ReturnType<TFn>} */ (entry);
[Symbol.iterator]() {
return this.#get_cache().values();
}
const new_entry = fn();
cache.set(key, new_entry);
return new_entry;
}
/**
* @template T
* @extends BaseCacheObserver<T>
*/
export class CacheObserver extends BaseCacheObserver {
constructor(prefix = '') {
if (!async_mode_flag) {
e.experimental_async_required('CacheObserver');
#get_cache() {
const store = get_render_context();
let map = store.cache.get(this.#key);
if (map === undefined) {
store.cache.set(this.#key, (map = new Map()));
}
super(() => get_render_context().cache, prefix);
return map;
}
}

@ -2,10 +2,12 @@
import { async_mode_flag } from '../../flags/index.js';
import { fetch_json } from '../../shared/utils.js';
import { hydratable } from '../hydratable.js';
import { cache } from './cache';
import { ReactiveCache } from './cache';
import { resource } from './resource.js';
import * as e from '../errors.js';
const fetch_cache = new ReactiveCache();
/**
* @template TReturn
* @param {string | URL} url
@ -16,6 +18,8 @@ export function fetcher(url, init) {
if (!async_mode_flag) {
e.experimental_async_required('fetcher');
}
const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`;
return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init))));
const key = `svelte/fetcher/${url}`;
return fetch_cache.register(key, () =>
resource(() => hydratable(key, () => fetch_json(url, init)))
);
}

@ -1,4 +1,4 @@
import type { CacheEntry, Encode } from '#shared';
import type { Encode } from '#shared';
import type { Element } from './dev';
import type { Renderer } from './renderer';
@ -23,7 +23,7 @@ export interface HydratableEntry {
export interface RenderContext {
hydratables: Map<string, HydratableEntry>;
cache: Map<string, CacheEntry>;
cache: Map<symbol, Map<string, any>>;
}
export interface SyncRenderOutput {

@ -1,78 +0,0 @@
/** @import { CacheEntry } from '#shared' */
/**
* @template T
* @implements {ReadonlyMap<string, T>} */
export class BaseCacheObserver {
/**
* This is a function so that you can create an ObservableCache instance globally and as long as you don't actually
* use it until you're inside the server render lifecycle you'll be okay
* @type {() => Map<string, CacheEntry>}
*/
#get_cache;
/** @type {string} */
#prefix;
/**
* @param {() => Map<string, CacheEntry>} get_cache
* @param {string} [prefix]
*/
constructor(get_cache, prefix = '') {
this.#get_cache = get_cache;
this.#prefix = prefix;
}
/** @param {string} key */
get(key) {
const entry = this.#get_cache().get(this.#key(key));
return entry?.item;
}
/** @param {string} key */
has(key) {
return this.#get_cache().has(this.#key(key));
}
get size() {
return [...this.keys()].length;
}
/** @param {(item: T, key: string, map: ReadonlyMap<string, T>) => void} cb */
forEach(cb) {
for (const [key, entry] of this.entries()) {
cb(entry, key, this);
}
}
*entries() {
for (const [key, entry] of this.#get_cache().entries()) {
if (!key.startsWith(this.#prefix)) continue;
yield /** @type {[string, T]} */ ([key, entry.item]);
}
return undefined;
}
*keys() {
for (const [key] of this.entries()) {
yield key;
}
return undefined;
}
*values() {
for (const [, entry] of this.entries()) {
yield entry;
}
return undefined;
}
[Symbol.iterator]() {
return this.entries();
}
/** @param {string} key */
#key(key) {
return this.#prefix + key;
}
}

@ -53,4 +53,4 @@ export type Resource<T> = {
export type GetRequestInit = Omit<RequestInit, 'method' | 'body'> & { method?: 'GET' };
export type CacheEntry = { count: number; item: any };
export type CacheEntry<T> = { count: number; item: T };

@ -7,7 +7,7 @@ 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/resource.js';
export { cache, CacheObserver } from '../internal/client/reactivity/cache.js';
export { ReactiveCache } from '../internal/client/reactivity/cache.js';
export { fetcher } from '../internal/client/reactivity/fetcher.js';
/**

@ -1,6 +1,6 @@
/** @import { Resource as ResourceType } from '#shared' */
export { resource } from '../internal/server/reactivity/resource.js';
export { cache, CacheObserver } from '../internal/server/reactivity/cache.js';
export { ReactiveCache } from '../internal/server/reactivity/cache.js';
export { fetcher } from '../internal/server/reactivity/fetcher.js';
export const SvelteDate = globalThis.Date;

Loading…
Cancel
Save