@brust/runtime-dom 2.0.0-alpha.3 → 2.0.0-alpha.30
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/README.md +15 -2
- package/package.json +19 -2
- package/src/attr-kinds.ts +6 -0
- package/src/core-api.ts +11 -0
- package/src/core.ts +8 -0
- package/src/directives/bind.ts +63 -16
- package/src/directives/common.ts +6 -0
- package/src/directives/for.ts +20 -12
- package/src/directives/html.ts +20 -0
- package/src/directives/if.ts +44 -11
- package/src/directives/index.ts +12 -1
- package/src/directives/model.ts +5 -7
- package/src/directives/on.ts +17 -2
- package/src/directives/ref.ts +2 -6
- package/src/directives/show.ts +1 -2
- package/src/directives/text.ts +1 -2
- package/src/features/basics.ts +6 -0
- package/src/features/collection.ts +40 -0
- package/src/features/for.ts +1 -0
- package/src/features/html.ts +1 -0
- package/src/features/if.ts +1 -0
- package/src/features/index.ts +9 -0
- package/src/features/island.ts +9 -0
- package/src/features/model.ts +1 -0
- package/src/features/store.ts +6 -0
- package/src/index.ts +4 -5
- package/src/instance.ts +76 -7
- package/src/island.ts +30 -9
- package/src/mount.ts +32 -10
- package/src/nav/chain.ts +57 -0
- package/src/nav/index.ts +5 -0
- package/src/nav/nav.ts +286 -0
- package/src/nav/swap.ts +160 -0
- package/src/props-bind.ts +10 -0
- package/src/registry.ts +6 -2
- package/src/signal.ts +15 -7
- package/src/store/define-store.ts +8 -10
- package/src/store/react.ts +3 -1
- package/src/store/registry.ts +39 -1
- package/src/directives/register.ts +0 -8
package/src/nav/nav.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// SPA navigation (design): a qualifying link click, `navigate()` or
|
|
2
|
+
// back/forward fetches the real HTML document and swaps it in place, so the page shows exactly what a full load of
|
|
3
|
+
// that URL shows (§2). Anything that cannot be swapped is a full load. Events: `brust:nav:click|load|success|error`.
|
|
4
|
+
import { applyHead, classicScripts, incomingImages, planHead, runScripts, swapBody } from './swap'
|
|
5
|
+
|
|
6
|
+
type Trigger = 'link' | 'navigate' | 'popstate'
|
|
7
|
+
export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void>; clearCache(): void }
|
|
8
|
+
/** How a module script of the new page is loaded; tests swap it. */
|
|
9
|
+
export type Load = (url: string) => Promise<unknown>
|
|
10
|
+
|
|
11
|
+
const KEY = Symbol.for('brust.nav')
|
|
12
|
+
const LOADING = 'data-brust-loading'
|
|
13
|
+
const NAV = 'script[data-brust-nav]'
|
|
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'
|
|
21
|
+
|
|
22
|
+
/** Boot once per tab. Returns a stop function (tests). */
|
|
23
|
+
export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => void {
|
|
24
|
+
const g = globalThis as { [KEY]?: NavBridge }
|
|
25
|
+
const build = document.querySelector(NAV)?.getAttribute('data-brust-build')
|
|
26
|
+
if (g[KEY] || build == null) return () => {}
|
|
27
|
+
const off = new AbortController()
|
|
28
|
+
const on = (t: EventTarget, type: string, h: (e: any) => void, capture?: boolean) =>
|
|
29
|
+
t.addEventListener(type, h, { capture, passive: type === 'touchstart', signal: off.signal })
|
|
30
|
+
const html = document.documentElement
|
|
31
|
+
const fire = (name: string, detail: object, cancelable?: boolean) =>
|
|
32
|
+
document.dispatchEvent(new CustomEvent('brust:nav:' + name, { detail, bubbles: true, cancelable }))
|
|
33
|
+
|
|
34
|
+
// ---- history entries: an id per entry in history.state.brust, scroll positions in memory (§5.7) ----
|
|
35
|
+
const stamp = Date.now().toString(36)
|
|
36
|
+
let n = 0
|
|
37
|
+
let seq = 0
|
|
38
|
+
const pos = new Map<string, [number, number]>()
|
|
39
|
+
const tag = (replaceUrl?: string) => {
|
|
40
|
+
const id = stamp + n++
|
|
41
|
+
history.replaceState({ ...history.state, brust: id }, '', replaceUrl)
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
let cur: string = history.state?.brust ?? tag()
|
|
45
|
+
let shown = location.href
|
|
46
|
+
history.scrollRestoration = 'manual'
|
|
47
|
+
const land = (url: string, at?: [number, number]) => {
|
|
48
|
+
if (at) return scrollTo(at[0], at[1])
|
|
49
|
+
let t: Element | null = null
|
|
50
|
+
try { const h = decodeURIComponent(new URL(url).hash.slice(1)); t = h ? document.getElementById(h) : null } catch {}
|
|
51
|
+
t ? t.scrollIntoView() : scrollTo(0, 0)
|
|
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. The <style> goes first in the head
|
|
75
|
+
// and its rules are one attribute selector each, so an app rule on div[data-brust-bar] wins ----
|
|
76
|
+
let bar: Element | undefined
|
|
77
|
+
let css: HTMLStyleElement | undefined
|
|
78
|
+
let wait: ReturnType<typeof setTimeout> | undefined
|
|
79
|
+
const progress = (on?: boolean) => {
|
|
80
|
+
clearTimeout(wait)
|
|
81
|
+
if (on) {
|
|
82
|
+
if (!bar && html.getAttribute('data-brust-progress') !== 'off') wait = setTimeout(() => {
|
|
83
|
+
if (!css) {
|
|
84
|
+
;(css = document.createElement('style')).textContent =
|
|
85
|
+
`div[${BAR}]{position:fixed;z-index:2147483647;top:0;left:0;width:100%;height:var(--brust-progress-height,2px);pointer-events:none;background:var(--brust-progress-color,#2f7cf6);transform-origin:0;transform:scaleX(0);animation:brust-bar 8s cubic-bezier(.1,.8,.2,1) forwards}` +
|
|
86
|
+
`div[${BAR}=done]{animation:none;transform:none;opacity:0;transition:transform .2s,opacity .3s .2s}@keyframes brust-bar{to{transform:scaleX(.8)}}` +
|
|
87
|
+
`@media (prefers-reduced-motion:reduce){div[${BAR}]{animation:none;transform:scaleX(.8)}div[${BAR}=done]{transition:none}}`
|
|
88
|
+
css.setAttribute(BAR, '')
|
|
89
|
+
}
|
|
90
|
+
if (!css.isConnected) document.head.prepend(css)
|
|
91
|
+
html.append((bar = document.createElement('div')))
|
|
92
|
+
bar.setAttribute(BAR, '')
|
|
93
|
+
}, 150)
|
|
94
|
+
} else if (bar) {
|
|
95
|
+
const b = bar
|
|
96
|
+
bar = undefined
|
|
97
|
+
b.setAttribute(BAR, 'done')
|
|
98
|
+
setTimeout(() => b.remove(), reduced() ? 0 : 500)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// the head elements that came from the server (there at boot, or inserted from a fetched document): the only ones the
|
|
103
|
+
// head reconcile may remove (§3 step 5)
|
|
104
|
+
const served = new WeakSet<Element>(document.head.children)
|
|
105
|
+
|
|
106
|
+
/** The URL a link navigates to, or null when the click (or a prefetch) is left to the browser (§5.1). */
|
|
107
|
+
const target = (t: EventTarget | null): [Element, string] | null => {
|
|
108
|
+
const a = (t as Element | null)?.closest?.('a')
|
|
109
|
+
const href = a?.getAttribute('href')
|
|
110
|
+
if (href == null || a!.hasAttribute('download') || (a!.getAttribute('target') || '_self').toLowerCase() !== '_self' || a!.closest('[data-brust-reload]')) return null
|
|
111
|
+
let u: URL
|
|
112
|
+
try { u = new URL(href, document.baseURI) } catch { return null }
|
|
113
|
+
if (u.origin !== location.origin || !/^https?:$/.test(u.protocol) || (u.href.includes('#') && sameDoc(u.href, location.href))) return null
|
|
114
|
+
return [a!, u.href]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const prefetch = (t: EventTarget | null) => {
|
|
118
|
+
const l = target(t)
|
|
119
|
+
const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
|
|
120
|
+
if (!l || l[0].closest('[data-brust-prefetch="false"]') || c?.saveData || /2g/.test(c?.effectiveType ?? '')) return
|
|
121
|
+
get(l[1], false, { priority: 'low' } as RequestInit).catch(() => {})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- one navigation (§3); a newer one supersedes it (and skips its transition) and it fires nothing more ----
|
|
125
|
+
let active = 0
|
|
126
|
+
let vt: VT | undefined
|
|
127
|
+
const go = async (req: string, trigger: Trigger, id: number, replace: boolean, pop?: string): Promise<void> => {
|
|
128
|
+
vt?.skipTransition()
|
|
129
|
+
const me = ++active
|
|
130
|
+
const live = () => me === active
|
|
131
|
+
const from = shown
|
|
132
|
+
let url = req
|
|
133
|
+
let status = 0
|
|
134
|
+
let pushed = false
|
|
135
|
+
const added: Element[] = []
|
|
136
|
+
html.setAttribute(LOADING, '')
|
|
137
|
+
progress(true)
|
|
138
|
+
fire('load', { id, url, from, trigger })
|
|
139
|
+
try {
|
|
140
|
+
// not aborted when superseded: the response still fills the cache
|
|
141
|
+
const res = await get(req, trigger === 'popstate')
|
|
142
|
+
if (!live()) return
|
|
143
|
+
status = res.status
|
|
144
|
+
// the final URL (after redirects); a fetch drops the fragment, which a redirect without one keeps
|
|
145
|
+
const fin = new URL(res.url || req)
|
|
146
|
+
// a redirect that left this origin (an open redirect to a CORS-readable page) is never parsed: a full load
|
|
147
|
+
if (fin.origin !== location.origin) throw new Error('cross-origin')
|
|
148
|
+
fin.hash ||= new URL(req).hash
|
|
149
|
+
url = fin.href
|
|
150
|
+
const doc = /html/.test(res.type) ? new DOMParser().parseFromString(res.text, 'text/html') : null
|
|
151
|
+
if (doc?.querySelector(NAV)?.getAttribute('data-brust-build') !== build) throw new Error(`not a brust page of this build`)
|
|
152
|
+
|
|
153
|
+
// §3 step 4: everything the new page needs before it is shown. The store tags first, at the end of the body, after
|
|
154
|
+
// the old ones (a store seeds from the first tag of its name; a live store never re-reads one): an old-page island
|
|
155
|
+
// that hydrates while the modules load still finds its own page's tag, and a store new to this page, first read
|
|
156
|
+
// by one of its modules, finds the new one. The swap removes them with the old body tail. Then the module scripts
|
|
157
|
+
// in document order (one at a time: the order they register in is the order the server wrote), then the
|
|
158
|
+
// stylesheets the head plan inserted, with `media="not all"`: loaded and parsed, applied to nothing until the swap.
|
|
159
|
+
const stores = Array.from(doc.querySelectorAll('script[data-brust-store]'), (s) => document.importNode(s, true))
|
|
160
|
+
document.body.append(...stores)
|
|
161
|
+
added.push(...stores)
|
|
162
|
+
for (const s of doc.querySelectorAll('script[type=module][src]')) {
|
|
163
|
+
if (!s.matches(NAV)) await load(new URL(s.getAttribute('src')!, url).href)
|
|
164
|
+
if (!live()) return
|
|
165
|
+
}
|
|
166
|
+
const plan = planHead([...doc.head.children])
|
|
167
|
+
added.push(...plan.sheets.map(([e]) => e))
|
|
168
|
+
// a load event is a later task: the handlers set now still catch a cached sheet
|
|
169
|
+
await Promise.all(plan.sheets.map(([e]) => new Promise((r) => { (e as HTMLLinkElement).onload = (e as HTMLLinkElement).onerror = r })))
|
|
170
|
+
if (!live()) return
|
|
171
|
+
|
|
172
|
+
// §3 step 5: swap (one task, nothing painted in between), then history and scroll
|
|
173
|
+
let fresh: Element[] = []
|
|
174
|
+
let body: HTMLScriptElement[] = []
|
|
175
|
+
let whole = false
|
|
176
|
+
const swap = () => {
|
|
177
|
+
if (!live()) return
|
|
178
|
+
for (const [e, m] of plan.sheets) m == null ? e.removeAttribute('media') : e.setAttribute('media', m)
|
|
179
|
+
pos.set(cur, [scrollX, scrollY])
|
|
180
|
+
for (const e of added) served.add(e)
|
|
181
|
+
fresh = applyHead(plan, (e) => served.has(e))
|
|
182
|
+
for (const e of fresh) served.add(e)
|
|
183
|
+
;[body, whole] = swapBody(doc, LOADING)
|
|
184
|
+
added.length = 0
|
|
185
|
+
if (trigger === 'popstate') cur = pop || tag()
|
|
186
|
+
else if (replace) history.replaceState({ ...history.state, brust: cur }, '', url)
|
|
187
|
+
else history.pushState({ brust: (cur = stamp + n++) }, '', url)
|
|
188
|
+
pushed = true
|
|
189
|
+
shown = location.href
|
|
190
|
+
land(url, pos.get(pop!))
|
|
191
|
+
}
|
|
192
|
+
// §10.3: the swap inside a view transition (a root cross-fade). Everything slow is done by now. The first 8 images the
|
|
193
|
+
// swap puts in (the changed level's, so a kept layout's never take a slot) that are not lazy and not on screen
|
|
194
|
+
// already are preloaded (the same candidate: srcset, sizes,
|
|
195
|
+
// crossorigin and referrerpolicy copied) and get 100 ms in all to decode, so none pops in after the fade. `swap`
|
|
196
|
+
// runs once: a throw from it rejects updateCallbackDone and takes the error path; a throw from startViewTransition
|
|
197
|
+
// itself is a direct swap.
|
|
198
|
+
const start = (document as { startViewTransition?: (u: () => void) => VT }).startViewTransition
|
|
199
|
+
let t: VT | undefined
|
|
200
|
+
if (start && html.getAttribute('data-brust-transition') !== 'off' && !reduced()) {
|
|
201
|
+
const abs = (u: string | null) => (u ? new URL(u, url).href : '')
|
|
202
|
+
const onScreen = new Set([...document.querySelectorAll('img')].flatMap((i) => [i.src, i.currentSrc]).filter(Boolean))
|
|
203
|
+
await Promise.race([
|
|
204
|
+
new Promise((r) => setTimeout(r, 100)),
|
|
205
|
+
Promise.all(incomingImages(doc)
|
|
206
|
+
.filter((i) => i.getAttribute('loading') !== 'lazy' && (i.hasAttribute('src') || i.hasAttribute('srcset')) && !onScreen.has(abs(i.getAttribute('src'))))
|
|
207
|
+
.slice(0, 8)
|
|
208
|
+
.map((i) => {
|
|
209
|
+
const m = new Image()
|
|
210
|
+
for (const a of ['crossorigin', 'referrerpolicy', 'sizes']) if (i.hasAttribute(a)) m.setAttribute(a, i.getAttribute(a)!)
|
|
211
|
+
const set = i.getAttribute('srcset')
|
|
212
|
+
if (set) m.srcset = set.replace(/(^|,)(\s*)([^\s,]+)/g, (_, c, w, u) => c + w + abs(u))
|
|
213
|
+
if (i.hasAttribute('src')) m.src = abs(i.getAttribute('src'))
|
|
214
|
+
return m.decode().catch(() => {})
|
|
215
|
+
})),
|
|
216
|
+
])
|
|
217
|
+
if (!live()) return
|
|
218
|
+
try { vt = t = start.call(document, swap) } catch {}
|
|
219
|
+
}
|
|
220
|
+
t ? await t.updateCallbackDone : swap()
|
|
221
|
+
if (!live()) return
|
|
222
|
+
// the head scripts new to it run; after a whole-body swap (<html> and <body> attributes replaced) every one the
|
|
223
|
+
// server sent runs again, as on a full load (a theme script setting <html class>), but a data-brust-once one only
|
|
224
|
+
// if it is new, and one a component added at runtime never (a loader such as Stripe.js must not run twice). A
|
|
225
|
+
// re-created script is the server's too, so the next reconcile still removes it.
|
|
226
|
+
const head = whole ? [...document.head.children].filter((e) => served.has(e) && (!e.hasAttribute('data-brust-once') || fresh.includes(e))) : fresh
|
|
227
|
+
await runScripts([...classicScripts(head), ...body], (s) => served.add(s))
|
|
228
|
+
if (!live()) return
|
|
229
|
+
html.removeAttribute(LOADING)
|
|
230
|
+
progress()
|
|
231
|
+
if (status < 400) fire('success', { id, url, from, trigger, status })
|
|
232
|
+
else fire('error', { id, url, from, trigger, status, error: undefined, swapped: true })
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!live()) return
|
|
235
|
+
html.removeAttribute(LOADING)
|
|
236
|
+
progress()
|
|
237
|
+
cache.delete(noHash(req))
|
|
238
|
+
// nothing could be swapped: the default action is a full load of the URL (cancelable)
|
|
239
|
+
if (fire('error', { id, url, from, trigger, status, error, swapped: false }, true)) location[pushed || trigger === 'popstate' ? 'replace' : 'assign'](url)
|
|
240
|
+
} finally {
|
|
241
|
+
for (const e of added) e.remove()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const navigate = (u: string, o?: { replace?: boolean }): Promise<void> => {
|
|
246
|
+
const url = new URL(u, location.href).href
|
|
247
|
+
if (url.startsWith(location.origin + '/') && !document.body.closest('[data-brust-reload]') && !(url.includes('#') && sameDoc(url, location.href)))
|
|
248
|
+
return go(url, 'navigate', ++seq, !!o?.replace || sameDoc(url, location.href))
|
|
249
|
+
location[o?.replace ? 'replace' : 'assign'](url)
|
|
250
|
+
return Promise.resolve()
|
|
251
|
+
}
|
|
252
|
+
g[KEY] = { navigate, clearCache: () => cache.clear() }
|
|
253
|
+
|
|
254
|
+
on(document, 'click', (e: MouseEvent) => {
|
|
255
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
256
|
+
const l = target(e.target)
|
|
257
|
+
if (!l) return
|
|
258
|
+
// the click navigates now: a hover intent still pending would refetch a page the cache does not keep
|
|
259
|
+
clearTimeout(hover)
|
|
260
|
+
const id = ++seq
|
|
261
|
+
if (!fire('click', { id, url: l[1], from: shown, link: l[0] }, true)) return
|
|
262
|
+
e.preventDefault()
|
|
263
|
+
void go(l[1], 'link', id, sameDoc(l[1], location.href))
|
|
264
|
+
})
|
|
265
|
+
let hover: ReturnType<typeof setTimeout> | undefined
|
|
266
|
+
const isA = (t: EventTarget | null) => (t as Element | null)?.localName === 'a'
|
|
267
|
+
on(document, 'pointerenter', (e) => { if (isA(e.target)) hover = setTimeout(() => prefetch(e.target), 65) }, true)
|
|
268
|
+
on(document, 'pointerleave', (e) => { if (isA(e.target)) clearTimeout(hover) }, true)
|
|
269
|
+
on(document, 'focusin', (e) => prefetch(e.target))
|
|
270
|
+
on(document, 'touchstart', (e) => prefetch(e.target), true)
|
|
271
|
+
on(window, 'popstate', (e: PopStateEvent) => {
|
|
272
|
+
const to: string | undefined = e.state?.brust
|
|
273
|
+
if (sameDoc(location.href, shown)) {
|
|
274
|
+
// a fragment entry of the page on screen: the browser owns it; keep the scroll of the entry left
|
|
275
|
+
pos.set(cur, [scrollX, scrollY])
|
|
276
|
+
cur = to || tag()
|
|
277
|
+
shown = location.href
|
|
278
|
+
return land(shown, pos.get(cur))
|
|
279
|
+
}
|
|
280
|
+
void go(location.href, 'popstate', ++seq, false, to)
|
|
281
|
+
})
|
|
282
|
+
return () => {
|
|
283
|
+
off.abort()
|
|
284
|
+
delete g[KEY]
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/nav/swap.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The DOM half of a navigation (design §3 step 5): the head made equal to the new head, then the body swapped whole or
|
|
2
|
+
// from one layout level down (§5.9), persisted elements moved over, and the new classic scripts run.
|
|
3
|
+
import { between, readChain, swapLevel } from './chain'
|
|
4
|
+
|
|
5
|
+
/** `sheets`: the inserted stylesheets with their own `media` (null: none); they carry `media="not all"` until the swap. */
|
|
6
|
+
const SHEET = 'link[rel~=stylesheet]'
|
|
7
|
+
|
|
8
|
+
export interface HeadPlan { want: Element[]; fixed: Set<Element>; sheets: [Element, string | null][] }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* §3 step 5, planned before the swap: each new head element is matched to an unused old one with the same outerHTML
|
|
12
|
+
* (kept, never re-created) or is new. The kept elements already in the right order (the longest such run) never move.
|
|
13
|
+
* A new stylesheet, or a kept one out of that run, is inserted now at its final place (a clone for a kept one): a moved
|
|
14
|
+
* `<link>` drops its sheet until a later task, a visible flash, so no loaded stylesheet ever moves. They go in with
|
|
15
|
+
* `media="not all"` (loaded and parsed, applied to nothing) and come back in `sheets` for the caller to await.
|
|
16
|
+
*/
|
|
17
|
+
export function planHead(next: Element[]): HeadPlan {
|
|
18
|
+
const head = document.head
|
|
19
|
+
const old = [...head.children]
|
|
20
|
+
const pool = new Map<string, Element[]>()
|
|
21
|
+
for (const el of old) { const k = el.outerHTML; pool.has(k) ? pool.get(k)!.push(el) : pool.set(k, [el]) }
|
|
22
|
+
const want = next.map((n) => pool.get(n.outerHTML)?.shift() ?? n)
|
|
23
|
+
// the heaviest run of old elements already in order, a stylesheet outweighing everything else (a head is small:
|
|
24
|
+
// quadratic is fine)
|
|
25
|
+
const at = want.map((el) => old.indexOf(el))
|
|
26
|
+
const w: number[] = []
|
|
27
|
+
const prev: number[] = []
|
|
28
|
+
let best = -1
|
|
29
|
+
at.forEach((v, i) => {
|
|
30
|
+
if (v < 0) return
|
|
31
|
+
const own = want[i]!.matches(SHEET) ? 1e3 : 1
|
|
32
|
+
w[i] = own
|
|
33
|
+
prev[i] = -1
|
|
34
|
+
for (let j = 0; j < i; j++) if (at[j]! >= 0 && at[j]! < v && w[j]! + own > w[i]!) { w[i] = w[j]! + own; prev[i] = j }
|
|
35
|
+
if (best < 0 || w[i]! > w[best]!) best = i
|
|
36
|
+
})
|
|
37
|
+
const fixed = new Set<Element>()
|
|
38
|
+
for (let i = best; i >= 0; i = prev[i]!) fixed.add(want[i]!)
|
|
39
|
+
const sheets: HeadPlan['sheets'] = []
|
|
40
|
+
want.forEach((el, i) => {
|
|
41
|
+
if (fixed.has(el) || !el.matches(SHEET)) return
|
|
42
|
+
const s = el.ownerDocument === document ? (el.cloneNode() as Element) : document.adoptNode(el)
|
|
43
|
+
sheets.push([s, s.getAttribute('media')])
|
|
44
|
+
s.setAttribute('media', 'not all')
|
|
45
|
+
head.insertBefore(s, want.slice(i + 1).find((e) => fixed.has(e)) ?? null)
|
|
46
|
+
fixed.add((want[i] = s))
|
|
47
|
+
})
|
|
48
|
+
return { want, fixed, sheets }
|
|
49
|
+
}
|
|
50
|
+
|
|
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[] {
|
|
56
|
+
const head = document.head
|
|
57
|
+
const keep = new Set(want)
|
|
58
|
+
for (const el of [...head.children]) if (!keep.has(el) && served(el)) el.remove()
|
|
59
|
+
const fresh: Element[] = []
|
|
60
|
+
let prev: Element | null = null
|
|
61
|
+
for (let el of want) {
|
|
62
|
+
if (el.ownerDocument !== document) fresh.push((el = document.adoptNode(el)))
|
|
63
|
+
// the fixed elements are already in order: every other one goes right after the one before it
|
|
64
|
+
if (!fixed.has(el) && (prev ? prev.nextElementSibling : head.firstElementChild) !== el) prev ? prev.after(el) : head.prepend(el)
|
|
65
|
+
prev = el
|
|
66
|
+
}
|
|
67
|
+
return fresh
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function copyAttrs(to: Element, from: Element, keep?: string): void {
|
|
71
|
+
for (const a of [...to.attributes]) if (a.name !== keep && !from.hasAttribute(a.name)) to.removeAttribute(a.name)
|
|
72
|
+
for (const a of from.attributes) to.setAttribute(a.name, a.value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const all = (nodes: Node[], sel: string) =>
|
|
76
|
+
nodes.flatMap((n) => (n instanceof Element ? [...(n.matches(sel) ? [n] : []), ...n.querySelectorAll(sel)] : []))
|
|
77
|
+
|
|
78
|
+
const P = '[data-brust-persist]'
|
|
79
|
+
const key = (el: Element) => el.getAttribute('data-brust-persist')!
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A live `data-brust-persist` element replaces the inserted element with the same key, in the same task as the swap:
|
|
83
|
+
* it never leaves this document (no adoption into the parsed one), so it is moved, never disposed.
|
|
84
|
+
*/
|
|
85
|
+
function persist(old: Node[]): (next: Node[]) => void {
|
|
86
|
+
const live = new Map(all(old, P).map((el) => [key(el), el]))
|
|
87
|
+
return (next) => { for (const el of all(next, P)) { const k = live.get(key(el)); if (k) el.replaceWith(k) } }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The body-end assets: the nav tag and every node after it (store tags, module scripts). */
|
|
91
|
+
function tail(body: HTMLElement): ChildNode[] | null {
|
|
92
|
+
const tag = body.querySelector(':scope > script[data-brust-nav]')
|
|
93
|
+
const out: ChildNode[] = []
|
|
94
|
+
for (let n: ChildNode | null = tag; n; n = n.nextSibling) out.push(n)
|
|
95
|
+
return tag && out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The images `swapBody(doc)` will put in, before it runs: the changed level's, or the whole body's. */
|
|
99
|
+
export function incomingImages(doc: Document): HTMLImageElement[] {
|
|
100
|
+
const b = readChain(doc)
|
|
101
|
+
const i = swapLevel(readChain(document), b)
|
|
102
|
+
return all(i && tail(document.body) && tail(doc.body) ? between(b![i]!) : [...doc.body.childNodes], 'img') as HTMLImageElement[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Swap the body to `doc`'s (§5.9): from the first level that changed, or the whole body (with the attributes of
|
|
107
|
+
* `<html>` and `<body>`) when the root changed or a chain cannot be trusted. Returns the classic scripts of the new
|
|
108
|
+
* nodes, taken before the persisted elements go in (a live one's scripts already ran: they never run again), and true
|
|
109
|
+
* for a whole-body swap.
|
|
110
|
+
*/
|
|
111
|
+
export function swapBody(doc: Document, loading: string): [HTMLScriptElement[], boolean] {
|
|
112
|
+
const body = document.body
|
|
113
|
+
const a = readChain(document)
|
|
114
|
+
const b = readChain(doc)
|
|
115
|
+
const i = swapLevel(a, b)
|
|
116
|
+
const ta = tail(body)
|
|
117
|
+
const tb = tail(doc.body)
|
|
118
|
+
if (i && ta && tb) {
|
|
119
|
+
const old = between(a![i]!)
|
|
120
|
+
const next = between(b![i]!)
|
|
121
|
+
const run = classicScripts([...next, ...tb])
|
|
122
|
+
const keep = persist(old)
|
|
123
|
+
// focus resets as on a full load, also when the focused element sits in a kept level
|
|
124
|
+
;(document.activeElement as HTMLElement | null)?.blur?.()
|
|
125
|
+
for (const n of old.slice(1)) n.remove()
|
|
126
|
+
old[0]!.replaceWith(...next)
|
|
127
|
+
keep(next)
|
|
128
|
+
for (const n of ta) n.remove()
|
|
129
|
+
body.append(...tb)
|
|
130
|
+
return [run, false]
|
|
131
|
+
}
|
|
132
|
+
copyAttrs(document.documentElement, doc.documentElement, loading)
|
|
133
|
+
copyAttrs(body, doc.body)
|
|
134
|
+
const next = [...doc.body.childNodes]
|
|
135
|
+
const run = classicScripts(next)
|
|
136
|
+
const keep = persist([...body.childNodes])
|
|
137
|
+
body.replaceChildren(...next)
|
|
138
|
+
keep(next)
|
|
139
|
+
return [run, true]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const isClassic = (s: Element) => !(s as HTMLScriptElement).type || /java|ecma/i.test((s as HTMLScriptElement).type)
|
|
143
|
+
|
|
144
|
+
/** The classic scripts among `nodes` (and under them), in document order. */
|
|
145
|
+
export const classicScripts = (nodes: Node[]) => all(nodes, 'script').filter(isClassic) as HTMLScriptElement[]
|
|
146
|
+
|
|
147
|
+
/** Run each script as a full load would: re-created (a parsed one never runs), one after the other, a `src` awaited.
|
|
148
|
+
* `ran` gets each replacement, which stands for the old node from then on. */
|
|
149
|
+
export async function runScripts(list: HTMLScriptElement[], ran?: (s: HTMLScriptElement) => void): Promise<void> {
|
|
150
|
+
for (const old of list) {
|
|
151
|
+
if (!old.isConnected) continue
|
|
152
|
+
const s = document.createElement('script')
|
|
153
|
+
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
|
154
|
+
s.text = old.text
|
|
155
|
+
const done = s.src && new Promise((r) => { s.onload = s.onerror = r })
|
|
156
|
+
old.replaceWith(s)
|
|
157
|
+
ran?.(s)
|
|
158
|
+
await done
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/props-bind.ts
CHANGED
|
@@ -9,6 +9,16 @@ function scopeOf(el: Element): Record<string, unknown> {
|
|
|
9
9
|
return {}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** F277: the props a linked host starts with: its parent's current `x-props-bind` value when the parent is mounted (a
|
|
13
|
+
* host an `x-if` or a row creates after first paint gets what the parent passes NOW, as React mounts with the current
|
|
14
|
+
* props: a `useState(prop)` starts from it), else its printed `x-props`. */
|
|
15
|
+
export function initialProps(host: HTMLElement, parent: Instance | null, printed: Record<string, unknown>): Record<string, unknown> {
|
|
16
|
+
const raw = host.getAttribute('x-props-bind'); if (!raw || !parent) return printed
|
|
17
|
+
const p = parseValue(raw); if (!p) return printed
|
|
18
|
+
const r = resolve(parent.members, p, scopeOf(host))
|
|
19
|
+
return r.ok && r.value && typeof r.value === 'object' ? (r.value as Record<string, unknown>) : printed
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export function bindPropsFromParent(inst: Instance): void {
|
|
13
23
|
const raw = inst.host.getAttribute('x-props-bind'); if (!raw) return
|
|
14
24
|
const p = parseValue(raw)
|
package/src/registry.ts
CHANGED
|
@@ -3,9 +3,13 @@ import type { Dispose, Signal } from './signal'
|
|
|
3
3
|
export interface BehaviorCtx {
|
|
4
4
|
el: HTMLElement
|
|
5
5
|
props: Signal<Record<string, unknown>>
|
|
6
|
-
effect: (fn: () => void | (() => void)) => Dispose
|
|
6
|
+
effect: (fn: () => void | (() => void), layout?: boolean) => Dispose
|
|
7
7
|
onCleanup: (fn: () => void) => void
|
|
8
|
-
ref: <E extends Element = HTMLElement>(name: string) => { current: E | null }
|
|
8
|
+
ref: <E extends Element = HTMLElement>(name: string, init?: unknown) => { current: E | null }
|
|
9
|
+
/** True when this instance mounted after the page booted (an x-if clone, an x-for row, a navigation): nothing of it was server-rendered. */
|
|
10
|
+
late: boolean
|
|
11
|
+
/** The collection items under the nearest collection root of this instance, in document order (R34.141). */
|
|
12
|
+
items: () => Array<{ ref: { current: Element }; element: Element } & Record<string, unknown>>
|
|
9
13
|
}
|
|
10
14
|
export type BehaviorInstance = Record<string, unknown>
|
|
11
15
|
export type BehaviorFactory = (ctx: BehaviorCtx) => BehaviorInstance | void
|
package/src/signal.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A small push-pull reactive core. Signals push "dirty" to subscribers; computeds pull lazily.
|
|
2
2
|
type Subscriber = { mark(): void; deps: Set<Node> }
|
|
3
3
|
type Node = { subs: Set<Subscriber> }
|
|
4
|
-
type Runnable = { disposed: boolean; run(): void }
|
|
4
|
+
type Runnable = { disposed: boolean; run(): void; tier?: number; id?: number }
|
|
5
5
|
interface ReactiveCtx { currentSub: Subscriber | null; batchDepth: number; pending: Set<Runnable> }
|
|
6
6
|
|
|
7
7
|
const CTX = Symbol.for('brust.reactive.ctx') // M3-F C7
|
|
@@ -23,8 +23,13 @@ function flush() {
|
|
|
23
23
|
if (ctx.batchDepth > 0) return
|
|
24
24
|
const pending = ctx.pending
|
|
25
25
|
while (pending.size) {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// F288 (ruling 94404307 amended): a re-run follows React's commit order: every binding of the change (tier 0, in
|
|
27
|
+
// notify order), then compiled layout effects (1), then compiled passive effects (2), each in declaration order.
|
|
28
|
+
const all = Array.from(pending)
|
|
29
|
+
const t = Math.min(...all.map((e) => e.tier || 0))
|
|
30
|
+
// a stable sort: bindings (t = 0) keep their notify order
|
|
31
|
+
const run = all.filter((e) => (e.tier || 0) === t).sort((a, b) => t && a.id! - b.id!)
|
|
32
|
+
for (const e of run) pending.delete(e), e.disposed || e.run()
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -67,9 +72,11 @@ export function computed<T>(fn: () => T): Computed<T> {
|
|
|
67
72
|
// Cleanups run untracked: a read inside one must not subscribe whichever effect is running around it.
|
|
68
73
|
function runCleanup(c: () => void) { const prev = ctx.currentSub; ctx.currentSub = null; try { c() } finally { ctx.currentSub = prev } }
|
|
69
74
|
|
|
75
|
+
let ids = 0
|
|
70
76
|
class EffectImpl implements Subscriber {
|
|
71
77
|
deps = new Set<Node>(); disposed = false; cleanup: void | (() => void) = undefined
|
|
72
|
-
|
|
78
|
+
readonly id = ++ids
|
|
79
|
+
constructor(private fn: () => void | (() => void), readonly tier = 0) {}
|
|
73
80
|
mark() { ctx.pending.add(this) }
|
|
74
81
|
run() {
|
|
75
82
|
if (this.disposed) return
|
|
@@ -86,9 +93,10 @@ class EffectImpl implements Subscriber {
|
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
e
|
|
96
|
+
/** `tier`: 0 a binding, 1 a compiled layout effect, 2 a compiled passive effect (the order of re-runs). */
|
|
97
|
+
export function effect(fn: () => void | (() => void), tier = 0): Dispose {
|
|
98
|
+
const e = new EffectImpl(fn, tier)
|
|
99
|
+
try { e.run() } catch (x) { e.dispose(); throw x } // F98: a first run that throws must not stay subscribed
|
|
92
100
|
return () => e.dispose()
|
|
93
101
|
}
|
|
94
102
|
|
|
@@ -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
|
-
import { type Computed, type Signal,
|
|
4
|
-
import { type StoreInstanceRecord, resolveRecord } from './registry'
|
|
3
|
+
import { type Computed, type Signal, isSignal } from '../core-api'
|
|
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
|
|
@@ -80,14 +80,12 @@ export function defineStore<S extends object>(name: string, factory: () => S): S
|
|
|
80
80
|
}
|
|
81
81
|
handle.snapshot = () => {
|
|
82
82
|
const rec = resolve()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
rec.snap = { value: out, version: rec.version.n }
|
|
90
|
-
return out as Snapshot<S>
|
|
83
|
+
return computeSnapshot(rec) as Snapshot<S>
|
|
84
|
+
}
|
|
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')] = () => {
|
|
87
|
+
const rec = resolve()
|
|
88
|
+
return ssrSnapshotOf(name, rec) ?? handle.snapshot()
|
|
91
89
|
}
|
|
92
90
|
handle.serialize = () => {
|
|
93
91
|
const out: Record<string, unknown> = {}
|
package/src/store/react.ts
CHANGED
|
@@ -10,7 +10,9 @@ export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<
|
|
|
10
10
|
// factory-default markup; the client resolves the real registry instance and hydrates.
|
|
11
11
|
const serverSnapshot = (): Snapshot<S> => {
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
// Hydration must see the markup the server rendered, not a value a native mount effect wrote since (#418).
|
|
14
|
+
const ssr = (store as unknown as Record<symbol, (() => Snapshot<S>) | undefined>)[Symbol.for('brust.ssrSnapshot')]
|
|
15
|
+
return ssr ? ssr() : store.snapshot()
|
|
14
16
|
} catch {
|
|
15
17
|
return {} as Snapshot<S>
|
|
16
18
|
}
|
package/src/store/registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Browser store registry + the server-resolver hook (M3-F D3/D6/C7).
|
|
2
2
|
// The registry lives on globalThis (C7) so two copies of this module share one instance per name.
|
|
3
|
+
import { isComputed, isSignal, untracked } from '../core-api'
|
|
3
4
|
import { parseStoreScript } from './serialize'
|
|
4
5
|
|
|
5
6
|
// The resolved per-scope store record, shared by the browser registry and the server per-request
|
|
@@ -11,9 +12,25 @@ export interface StoreInstanceRecord {
|
|
|
11
12
|
subs: Set<() => void>
|
|
12
13
|
version: { n: number }
|
|
13
14
|
snap: { value: Record<string, unknown>; version: number } | null
|
|
15
|
+
/** Browser only: the snapshot the SSR HTML was rendered from (script seed applied, nothing written since). Islands hydrate against it (G13). */
|
|
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
|
|
14
19
|
handle?: { serialize(): Record<string, unknown> }
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
/** Plain-value view of a record (signals and computeds read, actions dropped), memoized in `rec.snap` per version. */
|
|
23
|
+
export function computeSnapshot(rec: StoreInstanceRecord): Record<string, unknown> {
|
|
24
|
+
if (rec.snap && rec.snap.version === rec.version.n) return rec.snap.value
|
|
25
|
+
const out: Record<string, unknown> = {}
|
|
26
|
+
for (const key of Object.keys(rec.instance)) {
|
|
27
|
+
const v = (rec.instance as Record<string, unknown>)[key]
|
|
28
|
+
if (isSignal(v) || isComputed(v)) out[key] = v()
|
|
29
|
+
}
|
|
30
|
+
rec.snap = { value: out, version: rec.version.n }
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
export type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
|
|
18
35
|
|
|
19
36
|
const STORES = Symbol.for('brust.stores') // C7
|
|
@@ -58,13 +75,33 @@ export function resolveRecord(
|
|
|
58
75
|
const reg = browserRegistry()
|
|
59
76
|
let rec = reg[name]
|
|
60
77
|
if (!rec) {
|
|
61
|
-
rec = reg[name] = create(false)
|
|
78
|
+
const made = (rec = reg[name] = create(false))
|
|
62
79
|
const el = seedScript(name)
|
|
63
80
|
if (el) seed(rec, parseStoreScript(el))
|
|
81
|
+
rec.ssrSeed = el
|
|
82
|
+
// Identity-stable: the same object stays `rec.snap` until the first write.
|
|
83
|
+
rec.ssr = untracked(() => computeSnapshot(made)) // a first access inside an effect must not subscribe it
|
|
64
84
|
}
|
|
65
85
|
return rec
|
|
66
86
|
}
|
|
67
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
|
+
|
|
68
105
|
/** Apply a navigation-payload snapshot to live client stores. Reserved for the SPA wave (D9): no callers yet. */
|
|
69
106
|
export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
|
|
70
107
|
const reg = (globalThis as { [STORES]?: Registry })[STORES]
|
|
@@ -77,6 +114,7 @@ export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>
|
|
|
77
114
|
const v = inst[key]
|
|
78
115
|
if (v && typeof v.set === 'function') v.set(state[key])
|
|
79
116
|
}
|
|
117
|
+
entry.ssr = untracked(() => computeSnapshot(entry)) // R11.1: the payload is the new server truth for islands hydrating later
|
|
80
118
|
}
|
|
81
119
|
}
|
|
82
120
|
|