@brust/runtime-dom 2.0.0-alpha.26 → 2.0.0-alpha.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/nav/nav.ts +129 -38
- package/src/nav/swap.ts +10 -5
- package/src/store/define-store.ts +5 -2
- package/src/store/registry.ts +20 -0
package/package.json
CHANGED
package/src/nav/nav.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
// SPA navigation (design
|
|
1
|
+
// SPA navigation (design): a qualifying link click, `navigate()` or
|
|
2
2
|
// back/forward fetches the real HTML document and swaps it in place, so the page shows exactly what a full load of
|
|
3
3
|
// that URL shows (§2). Anything that cannot be swapped is a full load. Events: `brust:nav:click|load|success|error`.
|
|
4
4
|
import { applyHead, classicScripts, planHead, runScripts, swapBody } from './swap'
|
|
5
5
|
|
|
6
6
|
type Trigger = 'link' | 'navigate' | 'popstate'
|
|
7
|
-
export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void
|
|
7
|
+
export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void>; clearCache(): void }
|
|
8
8
|
/** How a module script of the new page is loaded; tests swap it. */
|
|
9
9
|
export type Load = (url: string) => Promise<unknown>
|
|
10
10
|
|
|
11
11
|
const KEY = Symbol.for('brust.nav')
|
|
12
12
|
const LOADING = 'data-brust-loading'
|
|
13
13
|
const NAV = 'script[data-brust-nav]'
|
|
14
|
-
const
|
|
14
|
+
const noHash = (u: string) => u.split('#')[0]!
|
|
15
|
+
const sameDoc = (a: string, b: string) => noHash(a) === noHash(b)
|
|
16
|
+
/** A fetched document as the cache holds it (§10.1). `no`: never kept (`no-store`, a status of 400 or more). */
|
|
17
|
+
interface Got { status: number; url: string; type: string; text: string; no: boolean }
|
|
18
|
+
type VT = { skipTransition(): void; updateCallbackDone: Promise<void> }
|
|
19
|
+
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
20
|
+
const BAR = 'data-brust-bar'
|
|
15
21
|
|
|
16
22
|
/** Boot once per tab. Returns a stop function (tests). */
|
|
17
23
|
export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => void {
|
|
@@ -45,14 +51,57 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
|
|
|
45
51
|
t ? t.scrollIntoView() : scrollTo(0, 0)
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
// ----
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// ---- the page cache (§10.1): an LRU of 32 documents by URL without its hash, filled by prefetches and navigations,
|
|
55
|
+
// fresh for 5 minutes (`data-brust-cache` seconds), any age for back/forward; a fetch in flight (at: Infinity) is shared
|
|
56
|
+
const cache = new Map<string, [Promise<Got>, number]>()
|
|
57
|
+
const ttl = () => { const s = html.getAttribute('data-brust-cache'); return s == null || isNaN(+s) ? 3e5 : +s * 1e3 }
|
|
58
|
+
const get = (u: string, any?: boolean, init?: RequestInit): Promise<Got> => {
|
|
59
|
+
const k = noHash(u)
|
|
60
|
+
let e = cache.get(k)
|
|
61
|
+
cache.delete(k)
|
|
62
|
+
if (!e || !(any || Date.now() - e[1] < ttl())) {
|
|
63
|
+
const p = fetch(k, init).then(async (r) => ({ status: r.status, url: r.url, type: r.headers.get('content-type') ?? '', text: await r.text(), no: r.status >= 400 || /no-store/i.test(r.headers.get('cache-control') ?? '') }))
|
|
64
|
+
const f = (e = [p, Infinity])
|
|
65
|
+
const drop = () => cache.get(k) === f && cache.delete(k)
|
|
66
|
+
p.then((g) => (g.no || !ttl() ? drop() : (f[1] = Date.now())), drop)
|
|
67
|
+
}
|
|
68
|
+
cache.set(k, e)
|
|
69
|
+
if (cache.size > 32) cache.delete(cache.keys().next().value!)
|
|
70
|
+
return e[0]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- the progress bar (§10.2): only for a navigation still loading 150 ms after `load`; outside <body>, so no swap
|
|
74
|
+
// removes it, and its <style> is runtime-added, so the head reconcile keeps it ----
|
|
75
|
+
let bar: Element | undefined
|
|
76
|
+
let css: HTMLStyleElement | undefined
|
|
77
|
+
let wait: ReturnType<typeof setTimeout> | undefined
|
|
78
|
+
const progress = (on?: boolean) => {
|
|
79
|
+
clearTimeout(wait)
|
|
80
|
+
if (on) {
|
|
81
|
+
if (!bar && html.getAttribute('data-brust-progress') !== 'off') wait = setTimeout(() => {
|
|
82
|
+
if (!css) {
|
|
83
|
+
;(css = document.createElement('style')).textContent =
|
|
84
|
+
`div[${BAR}]{position:fixed;z-index:2147483647;top:0;left:0;width:100%;height:3px;pointer-events:none;background:var(--brust-progress-color,#2f7cf6);transform-origin:0;transform:scaleX(0);animation:brust-bar 8s cubic-bezier(.1,.7,.3,1) forwards}` +
|
|
85
|
+
`div[${BAR}=done]{animation:none;transform:none;opacity:0;transition:transform .2s,opacity .3s .2s}@keyframes brust-bar{to{transform:scaleX(.9)}}` +
|
|
86
|
+
`@media (prefers-reduced-motion:reduce){div[${BAR}]{animation:none;transform:scaleX(.9)}}`
|
|
87
|
+
css.setAttribute(BAR, '')
|
|
88
|
+
}
|
|
89
|
+
if (!css.isConnected) document.head.append(css)
|
|
90
|
+
html.append((bar = document.createElement('div')))
|
|
91
|
+
bar.setAttribute(BAR, '')
|
|
92
|
+
}, 150)
|
|
93
|
+
} else if (bar) {
|
|
94
|
+
const b = bar
|
|
95
|
+
bar = undefined
|
|
96
|
+
b.setAttribute(BAR, 'done')
|
|
97
|
+
setTimeout(() => b.remove(), reduced() ? 0 : 500)
|
|
98
|
+
}
|
|
54
99
|
}
|
|
55
100
|
|
|
101
|
+
// the head elements that came from the server (there at boot, or inserted from a fetched document): the only ones the
|
|
102
|
+
// head reconcile may remove (§3 step 5)
|
|
103
|
+
const served = new WeakSet<Element>(document.head.children)
|
|
104
|
+
|
|
56
105
|
/** The URL a link navigates to, or null when the click (or a prefetch) is left to the browser (§5.1). */
|
|
57
106
|
const target = (t: EventTarget | null): [Element, string] | null => {
|
|
58
107
|
const a = (t as Element | null)?.closest?.('a')
|
|
@@ -68,28 +117,27 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
|
|
|
68
117
|
const l = target(t)
|
|
69
118
|
const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
|
|
70
119
|
if (!l || l[0].closest('[data-brust-prefetch="false"]') || c?.saveData || /2g/.test(c?.effectiveType ?? '')) return
|
|
71
|
-
|
|
72
|
-
if ((held.get(u)?.[1] ?? 0) > Date.now()) return
|
|
73
|
-
const p = fetch(u, { priority: 'low' } as RequestInit)
|
|
74
|
-
p.catch(() => {})
|
|
75
|
-
held.set(u, [p, Date.now() + 15e3])
|
|
120
|
+
get(l[1], false, { priority: 'low' } as RequestInit).catch(() => {})
|
|
76
121
|
}
|
|
77
122
|
|
|
78
|
-
// ---- one navigation (§3); a newer one
|
|
79
|
-
let active
|
|
123
|
+
// ---- one navigation (§3); a newer one supersedes it (and skips its transition) and it fires nothing more ----
|
|
124
|
+
let active = 0
|
|
125
|
+
let vt: VT | undefined
|
|
80
126
|
const go = async (req: string, trigger: Trigger, id: number, replace: boolean, pop?: string): Promise<void> => {
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const live = () =>
|
|
127
|
+
vt?.skipTransition()
|
|
128
|
+
const me = ++active
|
|
129
|
+
const live = () => me === active
|
|
84
130
|
const from = shown
|
|
85
131
|
let url = req
|
|
86
132
|
let status = 0
|
|
87
133
|
let pushed = false
|
|
88
134
|
const added: Element[] = []
|
|
89
135
|
html.setAttribute(LOADING, '')
|
|
136
|
+
progress(true)
|
|
90
137
|
fire('load', { id, url, from, trigger })
|
|
91
138
|
try {
|
|
92
|
-
|
|
139
|
+
// not aborted when superseded: the response still fills the cache
|
|
140
|
+
const res = await get(req, trigger === 'popstate')
|
|
93
141
|
if (!live()) return
|
|
94
142
|
status = res.status
|
|
95
143
|
// the final URL (after redirects); a fetch drops the fragment, which a redirect without one keeps
|
|
@@ -98,8 +146,7 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
|
|
|
98
146
|
if (fin.origin !== location.origin) throw new Error('cross-origin')
|
|
99
147
|
fin.hash ||= new URL(req).hash
|
|
100
148
|
url = fin.href
|
|
101
|
-
const doc = /html/.test(res.
|
|
102
|
-
if (!live()) return
|
|
149
|
+
const doc = /html/.test(res.type) ? new DOMParser().parseFromString(res.text, 'text/html') : null
|
|
103
150
|
if (doc?.querySelector(NAV)?.getAttribute('data-brust-build') !== build) throw new Error(`not a brust page of this build`)
|
|
104
151
|
|
|
105
152
|
// §3 step 4: everything the new page needs before it is shown. The store tags first, ahead of the old ones (a
|
|
@@ -120,28 +167,70 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
|
|
|
120
167
|
if (!live()) return
|
|
121
168
|
|
|
122
169
|
// §3 step 5: swap (one task, nothing painted in between), then history and scroll
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
let fresh: Element[] = []
|
|
171
|
+
let body: HTMLScriptElement[] = []
|
|
172
|
+
let whole = false
|
|
173
|
+
const swap = () => {
|
|
174
|
+
if (!live()) return
|
|
175
|
+
for (const [e, m] of plan.sheets) m == null ? e.removeAttribute('media') : e.setAttribute('media', m)
|
|
176
|
+
pos.set(cur, [scrollX, scrollY])
|
|
177
|
+
for (const e of added) served.add(e)
|
|
178
|
+
fresh = applyHead(plan, (e) => served.has(e))
|
|
179
|
+
for (const e of fresh) served.add(e)
|
|
180
|
+
;[body, whole] = swapBody(doc, LOADING)
|
|
181
|
+
added.length = 0
|
|
182
|
+
if (trigger === 'popstate') cur = pop || tag()
|
|
183
|
+
else if (replace) history.replaceState({ ...history.state, brust: cur }, '', url)
|
|
184
|
+
else history.pushState({ brust: (cur = stamp + n++) }, '', url)
|
|
185
|
+
pushed = true
|
|
186
|
+
shown = location.href
|
|
187
|
+
land(url, pos.get(pop!))
|
|
188
|
+
}
|
|
189
|
+
// §10.3: the swap inside a view transition (a root cross-fade). Everything slow is done by now. The first 8 images of
|
|
190
|
+
// the new page that are not lazy and not on screen already are preloaded (the same candidate: srcset, sizes,
|
|
191
|
+
// crossorigin and referrerpolicy copied) and get 100 ms in all to decode, so none pops in after the fade. `swap`
|
|
192
|
+
// runs once: a throw from it rejects updateCallbackDone and takes the error path; a throw from startViewTransition
|
|
193
|
+
// itself is a direct swap.
|
|
194
|
+
const start = (document as { startViewTransition?: (u: () => void) => VT }).startViewTransition
|
|
195
|
+
let t: VT | undefined
|
|
196
|
+
if (start && html.getAttribute('data-brust-transition') !== 'off' && !reduced()) {
|
|
197
|
+
const abs = (u: string | null) => (u ? new URL(u, url).href : '')
|
|
198
|
+
const onScreen = new Set([...document.querySelectorAll('img')].flatMap((i) => [i.src, i.currentSrc]).filter(Boolean))
|
|
199
|
+
await Promise.race([
|
|
200
|
+
new Promise((r) => setTimeout(r, 100)),
|
|
201
|
+
Promise.all([...doc.querySelectorAll('img')]
|
|
202
|
+
.filter((i) => i.getAttribute('loading') !== 'lazy' && (i.hasAttribute('src') || i.hasAttribute('srcset')) && !onScreen.has(abs(i.getAttribute('src'))))
|
|
203
|
+
.slice(0, 8)
|
|
204
|
+
.map((i) => {
|
|
205
|
+
const m = new Image()
|
|
206
|
+
for (const a of ['crossorigin', 'referrerpolicy', 'sizes']) if (i.hasAttribute(a)) m.setAttribute(a, i.getAttribute(a)!)
|
|
207
|
+
const set = i.getAttribute('srcset')
|
|
208
|
+
if (set) m.srcset = set.replace(/(^|,)(\s*)([^\s,]+)/g, (_, c, w, u) => c + w + abs(u))
|
|
209
|
+
if (i.hasAttribute('src')) m.src = abs(i.getAttribute('src'))
|
|
210
|
+
return m.decode().catch(() => {})
|
|
211
|
+
})),
|
|
212
|
+
])
|
|
213
|
+
if (!live()) return
|
|
214
|
+
try { vt = t = start.call(document, swap) } catch {}
|
|
215
|
+
}
|
|
216
|
+
t ? await t.updateCallbackDone : swap()
|
|
217
|
+
if (!live()) return
|
|
218
|
+
// the head scripts new to it run; after a whole-body swap (<html> and <body> attributes replaced) every one the
|
|
219
|
+
// server sent runs again, as on a full load (a theme script setting <html class>), but a data-brust-once one only
|
|
220
|
+
// if it is new, and one a component added at runtime never (a loader such as Stripe.js must not run twice). A
|
|
221
|
+
// re-created script is the server's too, so the next reconcile still removes it.
|
|
222
|
+
const head = whole ? [...document.head.children].filter((e) => served.has(e) && (!e.hasAttribute('data-brust-once') || fresh.includes(e))) : fresh
|
|
223
|
+
await runScripts([...classicScripts(head), ...body], (s) => served.add(s))
|
|
138
224
|
if (!live()) return
|
|
139
225
|
html.removeAttribute(LOADING)
|
|
226
|
+
progress()
|
|
140
227
|
if (status < 400) fire('success', { id, url, from, trigger, status })
|
|
141
228
|
else fire('error', { id, url, from, trigger, status, error: undefined, swapped: true })
|
|
142
229
|
} catch (error) {
|
|
143
230
|
if (!live()) return
|
|
144
231
|
html.removeAttribute(LOADING)
|
|
232
|
+
progress()
|
|
233
|
+
cache.delete(noHash(req))
|
|
145
234
|
// nothing could be swapped: the default action is a full load of the URL (cancelable)
|
|
146
235
|
if (fire('error', { id, url, from, trigger, status, error, swapped: false }, true)) location[pushed || trigger === 'popstate' ? 'replace' : 'assign'](url)
|
|
147
236
|
} finally {
|
|
@@ -156,12 +245,14 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
|
|
|
156
245
|
location[o?.replace ? 'replace' : 'assign'](url)
|
|
157
246
|
return Promise.resolve()
|
|
158
247
|
}
|
|
159
|
-
g[KEY] = { navigate }
|
|
248
|
+
g[KEY] = { navigate, clearCache: () => cache.clear() }
|
|
160
249
|
|
|
161
250
|
on(document, 'click', (e: MouseEvent) => {
|
|
162
251
|
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
163
252
|
const l = target(e.target)
|
|
164
253
|
if (!l) return
|
|
254
|
+
// the click navigates now: a hover intent still pending would refetch a page the cache does not keep
|
|
255
|
+
clearTimeout(hover)
|
|
165
256
|
const id = ++seq
|
|
166
257
|
if (!fire('click', { id, url: l[1], from: shown, link: l[0] }, true)) return
|
|
167
258
|
e.preventDefault()
|
package/src/nav/swap.ts
CHANGED
|
@@ -48,11 +48,14 @@ export function planHead(next: Element[]): HeadPlan {
|
|
|
48
48
|
return { want, fixed, sheets }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Make the head's element children `plan.want`, moving no fixed element. An old element not wanted is removed only if
|
|
53
|
+
* `served` says it came from the server: one a live component added at runtime stays. Returns the elements new to it.
|
|
54
|
+
*/
|
|
55
|
+
export function applyHead({ want, fixed }: HeadPlan, served: (el: Element) => boolean = () => true): Element[] {
|
|
53
56
|
const head = document.head
|
|
54
57
|
const keep = new Set(want)
|
|
55
|
-
for (const el of [...head.children]) if (!keep.has(el)) el.remove()
|
|
58
|
+
for (const el of [...head.children]) if (!keep.has(el) && served(el)) el.remove()
|
|
56
59
|
const fresh: Element[] = []
|
|
57
60
|
let prev: Element | null = null
|
|
58
61
|
for (let el of want) {
|
|
@@ -134,8 +137,9 @@ const isClassic = (s: Element) => !(s as HTMLScriptElement).type || /java|ecma/i
|
|
|
134
137
|
/** The classic scripts among `nodes` (and under them), in document order. */
|
|
135
138
|
export const classicScripts = (nodes: Node[]) => all(nodes, 'script').filter(isClassic) as HTMLScriptElement[]
|
|
136
139
|
|
|
137
|
-
/** Run each script as a full load would: re-created (a parsed one never runs), one after the other, a `src` awaited.
|
|
138
|
-
|
|
140
|
+
/** Run each script as a full load would: re-created (a parsed one never runs), one after the other, a `src` awaited.
|
|
141
|
+
* `ran` gets each replacement, which stands for the old node from then on. */
|
|
142
|
+
export async function runScripts(list: HTMLScriptElement[], ran?: (s: HTMLScriptElement) => void): Promise<void> {
|
|
139
143
|
for (const old of list) {
|
|
140
144
|
if (!old.isConnected) continue
|
|
141
145
|
const s = document.createElement('script')
|
|
@@ -143,6 +147,7 @@ export async function runScripts(list: HTMLScriptElement[]): Promise<void> {
|
|
|
143
147
|
s.text = old.text
|
|
144
148
|
const done = s.src && new Promise((r) => { s.onload = s.onerror = r })
|
|
145
149
|
old.replaceWith(s)
|
|
150
|
+
ran?.(s)
|
|
146
151
|
await done
|
|
147
152
|
}
|
|
148
153
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// defineStore (M3-F D1): the 0.1.x `brustjs/store` handle on the shared reactive core and the
|
|
2
2
|
// globalThis registry. Resolution (browser registry / server resolver) lives in ./registry.
|
|
3
3
|
import { type Computed, type Signal, isSignal } from '../core-api'
|
|
4
|
-
import { type StoreInstanceRecord, computeSnapshot, resolveRecord } from './registry'
|
|
4
|
+
import { type StoreInstanceRecord, computeSnapshot, resolveRecord, ssrSnapshotOf } from './registry'
|
|
5
5
|
|
|
6
6
|
// Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses `infer` (not
|
|
7
7
|
// `extends Signal<unknown>`): `Signal<T>.set(next: T)` is contravariant in T, so
|
|
@@ -83,7 +83,10 @@ export function defineStore<S extends object>(name: string, factory: () => S): S
|
|
|
83
83
|
return computeSnapshot(rec) as Snapshot<S>
|
|
84
84
|
}
|
|
85
85
|
// The snapshot the SSR HTML was rendered from (browser); React islands hydrate against it. Absent on the server.
|
|
86
|
-
;(handle as unknown as Record<symbol, unknown>)[Symbol.for('brust.ssrSnapshot')] = () =>
|
|
86
|
+
;(handle as unknown as Record<symbol, unknown>)[Symbol.for('brust.ssrSnapshot')] = () => {
|
|
87
|
+
const rec = resolve()
|
|
88
|
+
return ssrSnapshotOf(name, rec) ?? handle.snapshot()
|
|
89
|
+
}
|
|
87
90
|
handle.serialize = () => {
|
|
88
91
|
const out: Record<string, unknown> = {}
|
|
89
92
|
for (const [k, s] of Object.entries(resolve().instance)) {
|
package/src/store/registry.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StoreInstanceRecord {
|
|
|
14
14
|
snap: { value: Record<string, unknown>; version: number } | null
|
|
15
15
|
/** Browser only: the snapshot the SSR HTML was rendered from (script seed applied, nothing written since). Islands hydrate against it (G13). */
|
|
16
16
|
ssr?: Record<string, unknown>
|
|
17
|
+
/** Browser only: the seed tag `ssr` was read from (`null` when the page carried none). */
|
|
18
|
+
ssrSeed?: Element | null
|
|
17
19
|
handle?: { serialize(): Record<string, unknown> }
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -76,12 +78,30 @@ export function resolveRecord(
|
|
|
76
78
|
const made = (rec = reg[name] = create(false))
|
|
77
79
|
const el = seedScript(name)
|
|
78
80
|
if (el) seed(rec, parseStoreScript(el))
|
|
81
|
+
rec.ssrSeed = el
|
|
79
82
|
// Identity-stable: the same object stays `rec.snap` until the first write.
|
|
80
83
|
rec.ssr = untracked(() => computeSnapshot(made)) // a first access inside an effect must not subscribe it
|
|
81
84
|
}
|
|
82
85
|
return rec
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
const tagSnapshots = new WeakMap<Element, Record<string, unknown>>()
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The snapshot an island hydrating NOW must render from: the seed tag of the document it came from. A live store keeps
|
|
92
|
+
* `rec.ssr` from the first page (§5.5 of the SPA design), so an island on a page navigated to would otherwise hydrate
|
|
93
|
+
* against the first page's values. Keys the tag does not carry (computeds) keep the first snapshot's value.
|
|
94
|
+
*/
|
|
95
|
+
export function ssrSnapshotOf(name: string, rec: StoreInstanceRecord): Record<string, unknown> | undefined {
|
|
96
|
+
const base = rec.ssr
|
|
97
|
+
if (!base || typeof document === 'undefined') return base
|
|
98
|
+
const el = seedScript(name)
|
|
99
|
+
if (!el || el === rec.ssrSeed) return base
|
|
100
|
+
let snap = tagSnapshots.get(el)
|
|
101
|
+
if (!snap) tagSnapshots.set(el, (snap = { ...base, ...parseStoreScript(el) }))
|
|
102
|
+
return snap
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
/** Apply a navigation-payload snapshot to live client stores. Reserved for the SPA wave (D9): no callers yet. */
|
|
86
106
|
export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
|
|
87
107
|
const reg = (globalThis as { [STORES]?: Registry })[STORES]
|