Skip to content
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
da3260f
checkpoint; hydratable and base resource work
elliott-with-the-longest-name-on-github Oct 8, 2025
4d766f8
checkpoint
elliott-with-the-longest-name-on-github Oct 11, 2025
7c8c1ad
maximum hydration
elliott-with-the-longest-name-on-github Oct 15, 2025
83643ce
upgrade devalue
elliott-with-the-longest-name-on-github Oct 15, 2025
61d02fe
Merge remote-tracking branch 'origin' into elliott/resources
elliott-with-the-longest-name-on-github Oct 15, 2025
bca87b9
checkpoint
elliott-with-the-longest-name-on-github Oct 15, 2025
82be388
chore: temporarily remove fetcher
elliott-with-the-longest-name-on-github Oct 15, 2025
25210c2
types
elliott-with-the-longest-name-on-github Oct 15, 2025
9c7da6c
only generate hydratables when there's some amount of content
elliott-with-the-longest-name-on-github Oct 15, 2025
5de6383
add hash
elliott-with-the-longest-name-on-github Oct 15, 2025
8449ea7
making progress i think
elliott-with-the-longest-name-on-github Oct 21, 2025
ef11dae
typegen
elliott-with-the-longest-name-on-github Oct 21, 2025
d36894a
it at least basically works
elliott-with-the-longest-name-on-github Oct 21, 2025
0c4ce5a
misc improvements
elliott-with-the-longest-name-on-github Oct 24, 2025
a2bff0c
split stuff out, fix treeshaking
elliott-with-the-longest-name-on-github Oct 29, 2025
2e292b1
cache observer
elliott-with-the-longest-name-on-github Oct 29, 2025
7ee0ce8
fix export
elliott-with-the-longest-name-on-github Oct 29, 2025
90b85d1
add imperative hydratable API
elliott-with-the-longest-name-on-github Oct 30, 2025
598dc30
fix types
elliott-with-the-longest-name-on-github Oct 30, 2025
2c08d4f
fix types
elliott-with-the-longest-name-on-github Oct 30, 2025
5adb3f1
test
elliott-with-the-longest-name-on-github Oct 31, 2025
8179c6a
tests
elliott-with-the-longest-name-on-github Oct 31, 2025
6873685
temp fix
elliott-with-the-longest-name-on-github Oct 31, 2025
8939bbf
fix never-expiring cache entries
elliott-with-the-longest-name-on-github Nov 4, 2025
0f6001d
misc
elliott-with-the-longest-name-on-github Nov 4, 2025
8667dab
Merge remote-tracking branch 'origin' into elliott/resources
elliott-with-the-longest-name-on-github Nov 4, 2025
200b011
types
elliott-with-the-longest-name-on-github Nov 4, 2025
816ddca
import
elliott-with-the-longest-name-on-github Nov 4, 2025
7d44a1d
remove cruft, fix some type errors
elliott-with-the-longest-name-on-github Nov 4, 2025
cc094c5
.js .js .js wah wah wah
elliott-with-the-longest-name-on-github Nov 4, 2025
c6da91f
if you ignore your problems they go away
elliott-with-the-longest-name-on-github Nov 4, 2025
b36ba6d
unused
elliott-with-the-longest-name-on-github Nov 4, 2025
d6f240a
better serialization
elliott-with-the-longest-name-on-github Nov 4, 2025
7d0451e
oops
elliott-with-the-longest-name-on-github Nov 4, 2025
cd7a71f
oops
elliott-with-the-longest-name-on-github Nov 5, 2025
0581bb9
tweak
elliott-with-the-longest-name-on-github Nov 5, 2025
5aa7598
tweak
elliott-with-the-longest-name-on-github Nov 5, 2025
08d755b
tweak
elliott-with-the-longest-name-on-github Nov 5, 2025
9a424cd
add errors
elliott-with-the-longest-name-on-github Nov 5, 2025
e28ced7
tweak hydratable API, add official errors
elliott-with-the-longest-name-on-github Nov 6, 2025
aaf2eb8
fix types
elliott-with-the-longest-name-on-github Nov 6, 2025
555e950
Merge remote-tracking branch 'origin' into elliott/resources
elliott-with-the-longest-name-on-github Nov 6, 2025
d5fef8b
types
elliott-with-the-longest-name-on-github Nov 6, 2025
3f24dd2
Update packages/svelte/src/internal/server/renderer.js
elliott-with-the-longest-name-on-github Nov 6, 2025
551572e
errors
elliott-with-the-longest-name-on-github Nov 6, 2025
998510c
Merge branch 'elliott/resources' of github.com:sveltejs/svelte into e…
elliott-with-the-longest-name-on-github Nov 6, 2025
f9123f4
Update packages/svelte/messages/client-errors/errors.md
elliott-with-the-longest-name-on-github Nov 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
"types": "./types/index.d.ts",
"default": "./src/server/index.js"
},
"./client": {
"types": "./types/index.d.ts",
"default": "./src/client/index.js"
},
"./store": {
"types": "./types/index.d.ts",
"worker": "./src/store/index-server.js",
Expand Down Expand Up @@ -174,6 +178,7 @@
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.4.1",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"is-reference": "^3.0.3",
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/scripts/generate-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ await createBundle({
[`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`,
[`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`,
[`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`,
[`${pkg.name}/client`]: `${dir}/src/client/index.d.ts`,
[`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`,
[`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`,
[`${pkg.name}/events`]: `${dir}/src/events/public.d.ts`,
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/src/client/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
get_hydratable_value as getHydratableValue,
has_hydratable_value as hasHydratableValue
} from '../internal/client/hydratable.js';
1 change: 1 addition & 0 deletions packages/svelte/src/index-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export {
hasContext,
setContext
} from './internal/client/context.js';
export { hydratable } from './internal/client/hydratable.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';
2 changes: 2 additions & 0 deletions packages/svelte/src/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ export {
setContext
} from './internal/server/context.js';

export { hydratable } from './internal/server/hydratable.js';

export { createRawSnippet } from './internal/server/blocks/snippet.js';
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js';
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 { BRANCH_EFFECT } from './constants.js';

/** @type {ComponentContext | null} */
export let component_context = null;
Expand Down
66 changes: 66 additions & 0 deletions packages/svelte/src/internal/client/hydratable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** @import { Decode, Transport } from '#shared' */
import { hydrating } from './dom/hydration.js';

/**
* @template T
* @param {string} key
* @param {() => T} fn
* @param {Transport<T>} [options]
* @returns {T}
*/
export function hydratable(key, fn, options) {
if (!hydrating) {
return fn();
}
var store = window.__svelte?.h;
const val = store?.get(key);
if (val === undefined) {
// TODO this should really be an error or at least a warning because it would be disastrous to expect
// something to be synchronously hydratable and then have it not be
return fn();
}
return decode(val, options?.decode);
}

/**
* @template T
* @param {string} key
* @param {{ decode?: Decode<T> }} [options]
* @returns {T | undefined}
*/
export function get_hydratable_value(key, options = {}) {
// TODO probably can DRY this out with the above
if (!hydrating) {
return undefined;
}

var store = window.__svelte?.h;
const val = store?.get(key);
if (val === undefined) {
return undefined;
}

return decode(val, options.decode);
}

/**
* @param {string} key
* @returns {boolean}
*/
export function has_hydratable_value(key) {
if (!hydrating) {
return false;
}
var store = window.__svelte?.h;
return store?.has(key) ?? false;
}

/**
* @template T
* @param {unknown} val
* @param {Decode<T> | undefined} decode
* @returns {T}
*/
function decode(val, decode) {
return (decode ?? ((val) => /** @type {T} */ (val)))(val);
}
75 changes: 75 additions & 0 deletions packages/svelte/src/internal/client/reactivity/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** @import { CacheEntry } from '#shared' */
import { BaseCacheObserver } from '../../shared/cache-observer.js';
import { tick } from '../runtime.js';
import { get_effect_validation_error_code, render_effect } from './effects.js';

/** @typedef {{ count: number, item: any }} Entry */
/** @type {Map<string, CacheEntry>} */
const client_cache = new Map();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the intent here is for libraries to provide a prefix if they're going to use this API, should this be a two-tiered cache? Right now, if a library (like SvelteKit) wants to do something to everything it has in the cache, it has to iterate over all of the map entries, skipping the ones that don't start with its prefix, and refresh the things that do start with its prefix.

Maybe what we should do is make this two-tiered, where, if you don't provide a prefix, everything gets put into client_cache.get(''), but if you do, you get a namespaced cache. So in the case of SvelteKit, we'd end up with client_cache.get('@sveltejs/kit/remote'), which can be operated on as its own entity.

From an API perspective, you can provide a prefix as part of a third options argument to cache. Then, if you use CacheObserver, providing a prefix will automatically scope it to your cache.

The downside would be if you truly wanted to operate on the entire cache, which would be more complicated...


/**
* @template {(...args: any[]) => any} TFn
* @param {string} key
* @param {TFn} fn
* @returns {ReturnType<TFn>}
*/
export function cache(key, fn) {
const cached = client_cache.has(key);
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);
};
});
}

if (cached) {
return entry?.item;
}

const item = fn();
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);
}
});
}

/**
* @template T
* @extends BaseCacheObserver<T>
*/
export class CacheObserver extends BaseCacheObserver {
constructor(prefix = '') {
super(() => client_cache, prefix);
}
}
17 changes: 14 additions & 3 deletions packages/svelte/src/internal/client/reactivity/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,28 @@ import { without_reactive_context } from '../dom/elements/bindings/shared.js';
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
*/
export function validate_effect(rune) {
const code = get_effect_validation_error_code();
if (code === null) return;
e[code](rune);
}

/**
* @returns {'effect_orphan' | 'effect_in_unowned_derived' | 'effect_in_teardown' | null}
*/
export function get_effect_validation_error_code() {
if (active_effect === null && active_reaction === null) {
e.effect_orphan(rune);
return 'effect_orphan';
}

if (active_reaction !== null && (active_reaction.f & UNOWNED) !== 0 && active_effect === null) {
e.effect_in_unowned_derived();
return 'effect_in_unowned_derived';
}

if (is_destroying_effect) {
e.effect_in_teardown(rune);
return 'effect_in_teardown';
}

return null;
}

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/svelte/src/internal/client/reactivity/fetcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** @import { GetRequestInit, Resource } from '#shared' */
import { cache } from './cache';
import { fetch_json } from '../../shared/utils.js';
import { hydratable } from '../hydratable';
import { resource } from './resource';

/**
* @template TReturn
* @param {string | URL} url
* @param {GetRequestInit} [init]
* @returns {Resource<TReturn>}
*/
export function fetcher(url, init) {
const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how simple this is I'm quite tempted to say "nah, this can be an example in the docs" instead of shipping it as a core API...

return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init))));
}
Loading
Loading