-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat: hydratable and friends
#16960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: hydratable and friends
#16960
Changes from 38 commits
da3260f
4d766f8
7c8c1ad
83643ce
61d02fe
bca87b9
82be388
25210c2
9c7da6c
5de6383
8449ea7
ef11dae
d36894a
0c4ce5a
a2bff0c
2e292b1
7ee0ce8
90b85d1
598dc30
2c08d4f
5adb3f1
8179c6a
6873685
8939bbf
0f6001d
8667dab
200b011
816ddca
7d44a1d
cc094c5
c6da91f
b36ba6d
d6f240a
7d0451e
cd7a71f
0581bb9
5aa7598
08d755b
9a424cd
e28ced7
aaf2eb8
555e950
d5fef8b
3f24dd2
551572e
998510c
f9123f4
d5e4af8
61706dd
0ff9656
1a6b53b
141fd1e
eca531f
f9cdb76
a715fa1
cc74b6d
85abdbd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| 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); | ||
| } |
| 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(); | ||
|
||
|
|
||
| /** | ||
| * @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); | ||
elliott-with-the-longest-name-on-github marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) { | ||
elliott-with-the-longest-name-on-github marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this will break tree-shaking. we need to use dot notation, even if it's more code at the usage site
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. booo, I was wondering if that was the case. sad sad |
||
| } | ||
|
|
||
| /** | ||
| * @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; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| 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()}`; | ||||||
|
||||||
| const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; | |
| const key = `svelte/fetcher/${url}`; |
I definitely think there's value in shipping this — it'll be the most common way to consume resources other than remote functions (or maybe including remote functions), and even if we say 'it's a one-liner' it's a hell of a one-liner. Nesting three thunks and duplicating a key is kind of a lot, even once you understand why it's all necessary.
Of course it does mean we have to nail the fetcher API, though minus the TODO error message I'm not sure what could change. Unless we wanted to make any changes to the underlying primitives:
- we've probably had this convo already but are there any times we wouldn't want a resource to be cached, and if so does
resource(key, fn)make more sense thancache(key, () => resource(fn))? (We could still exposecachefor non-resource things of course) - do we have opinions about abort signals? obviously you could pass
signal: getAbortSignal()with yourinitargument but if you created/refreshed stuff outside the effect cycle then you can't use it. do we need to do something likeresource((signal) => ...)or no? (it would be a shame to have to create the abort signal eagerly. and maybe you don't want to abort previous fetches? so in summary, probably not, though i want to make sure we consider it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cache / no cache
My main thought around this is for long-lived resources or cases where you need to handle caching that doesn't fit perfectly within our "cached as long as it's reactively referenced" paradigm. Eg. It would be valid to do this:
export const space_ship = resource(get_random_ship);Where the "cache" is the module, i.e. "this should always exist". This just straight up is not possible with how cache is right now, nor do I think it's a use case we should really try to tackle with cache. It would be really weird to have to provide a key to the above declaration, and even weirder when another declaration with the same key didn't refer to the same object.
abort
Given we allow you to pass your own init, I think it's okay to expect people to pass in getAbortSignal() if they need that functionality.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HOLD ON A MINUTE
Why do we need to provide a cache key at all? The function is the key!
If we do that, then the space_ship example makes perfect sense — we just don't use the cache when cache is called outside a tracking context.
Of course there are still cases where you do want deduping, e.g. two fetcher(...) occurrences with the same argument. So maybe it's like this:
function cache(fn, key = fn) {
// ...
}Abort signals — actually I realised it's not as straightforward as passing it in init, because you might be calling fetcher outside an effect, in which case you can't call getAbortSignal. So if you did want cancellation it would have to be managed by fetcher rather than by Svelte's lifecycle, which suggests adding an options object separate from init... hmm. Maybe 'nail the fetcher API' isn't quite as trivial as we thought.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Errr... wait. I think I had a brain malfunction. That won't work in a lot of cases. But maybe it works in the cases like space_ship?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This just straight up is not possible with how
cacheis right now
To be clear, it is possible — it works fine. It just doesn't cache the result for longer than a microtask because it's being created outside a tracking context, and that's fine.
But, yeah... it's also pointless. The only times you'd call resource outside a tracking context are the times when you're holding a reference to the resource yourself, in which case you don't need it to be cached. When you do need it to be cached, it's because you're calling it with a fresh arrow function each time which is obviously no good as a key.
Look forward to having this conversation with myself again in a few weeks when I've forgotten this one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll save a link to this just in case, will be ready to fire it off to you 😂
Uh oh!
There was an error while loading. Please reload this page.