@bitstillery/mithril 3.4.2 → 3.7.0
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/api/router.ts +9 -12
- package/index.ts +1 -1
- package/package.json +1 -1
- package/render/cachedAttrsIsStaticMap.ts +5 -1
- package/render/render.ts +199 -51
- package/render/ssrState.ts +5 -1
- package/server/logger.ts +136 -29
- package/server/ssr.ts +49 -20
- package/ssrContext.ts +5 -0
- package/state.ts +16 -12
- package/store.ts +113 -24
- package/util/ssr.ts +73 -1
package/api/router.ts
CHANGED
|
@@ -100,8 +100,13 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
100
100
|
const vnode = Vnode(component, undefined, routeAttrs, null, null, null)
|
|
101
101
|
if (currentResolver) {
|
|
102
102
|
const result = currentResolver.render!(vnode as any)
|
|
103
|
-
//
|
|
104
|
-
//
|
|
103
|
+
// SSR `renderToString(resolver.render(...))` has no outer fragment. Wrapping here breaks
|
|
104
|
+
// hydration: `createFragment` uses an empty DocumentFragment as parent, so descendant
|
|
105
|
+
// nodes never match `#app`'s existing SSR children (blank tree / mismatch recovery).
|
|
106
|
+
// After `remount: true`, bump `remountNonce` and use a keyed fragment to force remount.
|
|
107
|
+
if (remountNonce === 0) {
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
105
110
|
return hyperscript.fragment({key: 'm-route-' + remountNonce}, result)
|
|
106
111
|
}
|
|
107
112
|
// Wrap in a fragment to preserve existing key semantics
|
|
@@ -360,9 +365,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
360
365
|
}
|
|
361
366
|
route.get = function (): string {
|
|
362
367
|
// If currentPath is not set (e.g., during SSR before route.resolve is called),
|
|
363
|
-
// fall back to extracting pathname from __SSR_URL__ using the isomorphic URI API
|
|
368
|
+
// fall back to extracting pathname + search from __SSR_URL__ using the isomorphic URI API
|
|
364
369
|
if (currentPath === undefined) {
|
|
365
|
-
return getPathname()
|
|
370
|
+
return getPathname() + getSearch()
|
|
366
371
|
}
|
|
367
372
|
return currentPath ?? ''
|
|
368
373
|
}
|
|
@@ -610,14 +615,6 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
610
615
|
// resolver.render does: m(layout, null, componentVnode)
|
|
611
616
|
const renderedVnode = resolver.render(componentVnode)
|
|
612
617
|
const result = await renderToString(renderedVnode)
|
|
613
|
-
const html = typeof result === 'string' ? result : result.html
|
|
614
|
-
if (html) {
|
|
615
|
-
logger.info(`rendered route component`, {
|
|
616
|
-
pathname,
|
|
617
|
-
route: matchedRoute,
|
|
618
|
-
htmlSize: html.length,
|
|
619
|
-
})
|
|
620
|
-
}
|
|
621
618
|
return result
|
|
622
619
|
} catch (error) {
|
|
623
620
|
logger.error('route render failed', error, {
|
package/index.ts
CHANGED
|
@@ -102,7 +102,7 @@ setSignalRedrawCallback((sig: Signal<any>) => {
|
|
|
102
102
|
|
|
103
103
|
// Export signals API
|
|
104
104
|
export {signal, computed, effect, Signal, ComputedSignal, state, watch, registerState, getRegisteredStates, clearStateRegistry}
|
|
105
|
-
export type {State, StateOptions, StateSignals, Unwatch} from './state'
|
|
105
|
+
export type {State, StateArray, StateOptions, StateSignals, Unwatch} from './state'
|
|
106
106
|
|
|
107
107
|
// Export Store class
|
|
108
108
|
export {Store} from './store'
|
package/package.json
CHANGED
|
@@ -7,4 +7,8 @@ import emptyAttrs from './emptyAttrs'
|
|
|
7
7
|
//
|
|
8
8
|
// Since the attrs used as keys in this map are not released from the selectorCache object,
|
|
9
9
|
// there is no risk of memory leaks. Therefore, Map is used here instead of WeakMap.
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
const map = new Map<Record<string, any>, boolean>()
|
|
12
|
+
// Each Mithril instance registers its own emptyAttrs as static.
|
|
13
|
+
map.set(emptyAttrs, true)
|
|
14
|
+
export default map
|
package/render/render.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import {setCurrentComponent, clearCurrentComponent, clearComponentDependencies} from '../signal'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
logHydrationError,
|
|
4
|
+
recordHydrationMismatchSummary,
|
|
5
|
+
resetHydrationErrorCount,
|
|
6
|
+
takeHydrationMismatchSummaries,
|
|
7
|
+
} from '../util/ssr'
|
|
3
8
|
import {logger} from '../server/logger'
|
|
4
9
|
|
|
5
10
|
import Vnode from './vnode'
|
|
@@ -71,6 +76,156 @@ export default function renderFactory() {
|
|
|
71
76
|
return null
|
|
72
77
|
}
|
|
73
78
|
}
|
|
79
|
+
// Positional hydration: walk existing DOM children in order, adopting each
|
|
80
|
+
// to the corresponding vnode. Handles text-node merging (SSR concatenates
|
|
81
|
+
// adjacent text runs into one node) and component/fragment recursion.
|
|
82
|
+
// Returns the next unprocessed DOM sibling (or null when exhausted).
|
|
83
|
+
function hydrateNode(
|
|
84
|
+
parent: Element,
|
|
85
|
+
vnode: any,
|
|
86
|
+
hooks: Array<() => void>,
|
|
87
|
+
ns: string | undefined,
|
|
88
|
+
cursor: Node | null,
|
|
89
|
+
): Node | null {
|
|
90
|
+
if (vnode == null) return cursor
|
|
91
|
+
const tag = vnode.tag
|
|
92
|
+
|
|
93
|
+
if (typeof tag === 'string') {
|
|
94
|
+
switch (tag) {
|
|
95
|
+
case '#': {
|
|
96
|
+
// Text vnode
|
|
97
|
+
if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks, true)
|
|
98
|
+
const expected = String(vnode.children)
|
|
99
|
+
if (cursor && cursor.nodeType === 3) {
|
|
100
|
+
// Reuse existing text node; patch value if it differs
|
|
101
|
+
vnode.dom = cursor as Text
|
|
102
|
+
if ((cursor as Text).nodeValue !== expected) {
|
|
103
|
+
;(cursor as Text).nodeValue = expected
|
|
104
|
+
}
|
|
105
|
+
return cursor.nextSibling
|
|
106
|
+
}
|
|
107
|
+
// SSR may have merged multiple text vnodes into a single text node.
|
|
108
|
+
// Check if the current text node *starts* with our expected text.
|
|
109
|
+
if (cursor && cursor.nodeType === 3) {
|
|
110
|
+
const full = (cursor as Text).nodeValue || ''
|
|
111
|
+
if (full.startsWith(expected)) {
|
|
112
|
+
// Split: take our portion, leave the rest for the next vnode
|
|
113
|
+
;(cursor as Text).splitText(expected.length)
|
|
114
|
+
vnode.dom = cursor as Text
|
|
115
|
+
return cursor.nextSibling
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// No matching text node — create one
|
|
119
|
+
const textNode = getDocument(parent).createTextNode(expected)
|
|
120
|
+
parent.insertBefore(textNode, cursor)
|
|
121
|
+
vnode.dom = textNode
|
|
122
|
+
return cursor
|
|
123
|
+
}
|
|
124
|
+
case '<': {
|
|
125
|
+
// Trust HTML vnode — skip the corresponding DOM range.
|
|
126
|
+
// Fall through to non-hydration creation (rare in hydration).
|
|
127
|
+
vnode.state = {}
|
|
128
|
+
if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks, true)
|
|
129
|
+
createHTML(parent, vnode, ns, cursor)
|
|
130
|
+
return cursor
|
|
131
|
+
}
|
|
132
|
+
case '[': {
|
|
133
|
+
// Fragment vnode
|
|
134
|
+
vnode.state = {}
|
|
135
|
+
if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks, true)
|
|
136
|
+
let c = cursor
|
|
137
|
+
if (vnode.children != null) {
|
|
138
|
+
for (let i = 0; i < vnode.children.length; i++) {
|
|
139
|
+
c = hydrateNode(parent, vnode.children[i], hooks, ns, c)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
vnode.dom = vnode.children?.[0]?.dom ?? null
|
|
143
|
+
let size = 0
|
|
144
|
+
if (vnode.children) {
|
|
145
|
+
for (let i = 0; i < vnode.children.length; i++) {
|
|
146
|
+
const child = vnode.children[i]
|
|
147
|
+
if (child != null) size += child.domSize ?? (child.dom ? 1 : 0)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
vnode.domSize = size
|
|
151
|
+
return c
|
|
152
|
+
}
|
|
153
|
+
default: {
|
|
154
|
+
// Element vnode
|
|
155
|
+
vnode.state = {}
|
|
156
|
+
if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks, true)
|
|
157
|
+
// Find matching element (skip whitespace text nodes)
|
|
158
|
+
let el: Element | null = null
|
|
159
|
+
let scan: Node | null = cursor
|
|
160
|
+
while (scan) {
|
|
161
|
+
if (scan.nodeType === 1) {
|
|
162
|
+
const candidateTag = ((scan as Element).tagName || (scan as Element).nodeName || '').toLowerCase()
|
|
163
|
+
if (candidateTag === tag.toLowerCase()) {
|
|
164
|
+
el = scan as Element
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
break // tag mismatch — don't skip ahead indefinitely
|
|
168
|
+
}
|
|
169
|
+
// Skip whitespace-only text nodes between elements
|
|
170
|
+
if (scan.nodeType === 3 && scan.nodeValue && scan.nodeValue.trim() === '') {
|
|
171
|
+
scan = scan.nextSibling
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
if (el) {
|
|
177
|
+
vnode.dom = el
|
|
178
|
+
ns = getNameSpace(vnode) || ns
|
|
179
|
+
if (vnode.attrs != null) setAttrs(vnode, vnode.attrs, ns)
|
|
180
|
+
if (!maybeSetContentEditable(vnode)) {
|
|
181
|
+
if (vnode.children != null) {
|
|
182
|
+
hydrateElementChildren(el, vnode.children, hooks, ns)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
vnode.domSize = 1
|
|
186
|
+
return el.nextSibling
|
|
187
|
+
}
|
|
188
|
+
// No matching element — create from scratch
|
|
189
|
+
createElement(parent, vnode, hooks, ns, cursor)
|
|
190
|
+
return cursor
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Component vnode
|
|
196
|
+
initComponent(vnode, hooks, true)
|
|
197
|
+
if (vnode.instance != null) {
|
|
198
|
+
const nextCursor = hydrateNode(parent, vnode.instance, hooks, ns, cursor)
|
|
199
|
+
vnode.dom = vnode.instance.dom
|
|
200
|
+
vnode.domSize = vnode.instance.domSize
|
|
201
|
+
return nextCursor
|
|
202
|
+
}
|
|
203
|
+
vnode.domSize = 0
|
|
204
|
+
return cursor
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hydrateElementChildren(
|
|
208
|
+
element: Element,
|
|
209
|
+
children: (VnodeType | null)[],
|
|
210
|
+
hooks: Array<() => void>,
|
|
211
|
+
ns: string | undefined,
|
|
212
|
+
) {
|
|
213
|
+
let cursor: Node | null = element.firstChild
|
|
214
|
+
for (let i = 0; i < children.length; i++) {
|
|
215
|
+
cursor = hydrateNode(element, children[i], hooks, ns, cursor)
|
|
216
|
+
}
|
|
217
|
+
// Remove leftover DOM nodes that weren't claimed by any vnode
|
|
218
|
+
while (cursor) {
|
|
219
|
+
const next: Node | null = cursor.nextSibling
|
|
220
|
+
try {
|
|
221
|
+
element.removeChild(cursor)
|
|
222
|
+
} catch (_e) {
|
|
223
|
+
// already removed
|
|
224
|
+
}
|
|
225
|
+
cursor = next
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
74
229
|
// create
|
|
75
230
|
function createNodes(
|
|
76
231
|
parent: Element | DocumentFragment,
|
|
@@ -234,14 +389,35 @@ export default function renderFactory() {
|
|
|
234
389
|
isHydrating: boolean = false,
|
|
235
390
|
matchedNodes: Set<Node> | null = null,
|
|
236
391
|
) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
392
|
+
if (isHydrating && matchedNodes) {
|
|
393
|
+
// During hydration, render children directly into the real parent so they
|
|
394
|
+
// can match existing SSR DOM nodes. Using a DocumentFragment would isolate
|
|
395
|
+
// the children from the parent's childNodes, preventing any reuse.
|
|
396
|
+
const childCountBefore = parent.childNodes.length
|
|
397
|
+
if (vnode.children != null) {
|
|
398
|
+
const children = vnode.children
|
|
399
|
+
createNodes(parent, children, 0, children.length, hooks, nextSibling, ns, isHydrating, matchedNodes)
|
|
400
|
+
}
|
|
401
|
+
// Fragment's dom/domSize must reflect the children that were placed
|
|
402
|
+
vnode.dom = vnode.children?.[0]?.dom ?? null
|
|
403
|
+
let size = 0
|
|
404
|
+
if (vnode.children) {
|
|
405
|
+
for (let i = 0; i < vnode.children.length; i++) {
|
|
406
|
+
const child = vnode.children[i]
|
|
407
|
+
if (child != null) size += child.domSize ?? (child.dom ? 1 : 0)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
vnode.domSize = size
|
|
411
|
+
} else {
|
|
412
|
+
const fragment = getDocument(parent as Element).createDocumentFragment()
|
|
413
|
+
if (vnode.children != null) {
|
|
414
|
+
const children = vnode.children
|
|
415
|
+
createNodes(fragment, children, 0, children.length, hooks, null, ns, isHydrating, matchedNodes)
|
|
416
|
+
}
|
|
417
|
+
vnode.dom = fragment.firstChild
|
|
418
|
+
vnode.domSize = fragment.childNodes.length
|
|
419
|
+
insertDOM(parent, fragment, nextSibling)
|
|
241
420
|
}
|
|
242
|
-
vnode.dom = fragment.firstChild
|
|
243
|
-
vnode.domSize = fragment.childNodes.length
|
|
244
|
-
insertDOM(parent, fragment, nextSibling)
|
|
245
421
|
}
|
|
246
422
|
function createElement(
|
|
247
423
|
parent: Element | DocumentFragment,
|
|
@@ -326,44 +502,13 @@ export default function renderFactory() {
|
|
|
326
502
|
if (!maybeSetContentEditable(vnode)) {
|
|
327
503
|
if (vnode.children != null) {
|
|
328
504
|
const children = vnode.children
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
let node: Node | null = element.firstChild
|
|
337
|
-
while (node) {
|
|
338
|
-
const next: Node | null = node.nextSibling
|
|
339
|
-
if (!childMatchedNodes.has(node)) {
|
|
340
|
-
// Verify node is still a child before attempting removal
|
|
341
|
-
if (element.contains && element.contains(node)) {
|
|
342
|
-
try {
|
|
343
|
-
element.removeChild(node)
|
|
344
|
-
hydrationMismatchCount++
|
|
345
|
-
} catch (e) {
|
|
346
|
-
const error = e as Error
|
|
347
|
-
// Check if node was already removed (not a child anymore)
|
|
348
|
-
if (!element.contains || !element.contains(node)) {
|
|
349
|
-
// Node already removed, skip silently
|
|
350
|
-
node = next
|
|
351
|
-
continue
|
|
352
|
-
}
|
|
353
|
-
hydrationMismatchCount++
|
|
354
|
-
logHydrationError('removeChild (element children cleanup)', vnode, element, error, {
|
|
355
|
-
parent: element,
|
|
356
|
-
node,
|
|
357
|
-
matchedNodes: childMatchedNodes,
|
|
358
|
-
})
|
|
359
|
-
// Don't re-throw - we've already logged the error with all details
|
|
360
|
-
// Re-throwing causes the browser to log the DOMException stack trace
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
// Node not in parent, already removed - skip silently
|
|
364
|
-
}
|
|
365
|
-
node = next
|
|
366
|
-
}
|
|
505
|
+
if (isHydrating && element.firstChild) {
|
|
506
|
+
// Positional adoption: walk existing DOM children in order, assigning
|
|
507
|
+
// each to the corresponding vnode. This avoids the content-matching
|
|
508
|
+
// approach which breaks when SSR merges adjacent text nodes.
|
|
509
|
+
hydrateElementChildren(element, children, hooks, ns)
|
|
510
|
+
} else {
|
|
511
|
+
createNodes(element, children, 0, children.length, hooks, null, ns)
|
|
367
512
|
}
|
|
368
513
|
if (vnode.tag === 'select' && attrs != null) setLateSelectAttrs(vnode, attrs)
|
|
369
514
|
}
|
|
@@ -1194,13 +1339,13 @@ export default function renderFactory() {
|
|
|
1194
1339
|
isHydrating: isHydrating,
|
|
1195
1340
|
}
|
|
1196
1341
|
const result = callHook.call(source.oninit, vnode, context)
|
|
1197
|
-
// Auto-redraw when async oninit completes (client-side only)
|
|
1342
|
+
// Auto-redraw when async oninit completes (client-side only).
|
|
1343
|
+
// Capture currentRedraw now — the closure variable is cleared in
|
|
1344
|
+
// render()'s finally block before the microtask fires.
|
|
1198
1345
|
if (result != null && typeof result.then === 'function' && currentRedraw != null) {
|
|
1346
|
+
const capturedRedraw = currentRedraw
|
|
1199
1347
|
Promise.resolve(result).then(function () {
|
|
1200
|
-
|
|
1201
|
-
// @ts-expect-error - Comma operator intentionally used to call without 'this' binding
|
|
1202
|
-
;(0, currentRedraw)()
|
|
1203
|
-
}
|
|
1348
|
+
capturedRedraw()
|
|
1204
1349
|
})
|
|
1205
1350
|
}
|
|
1206
1351
|
}
|
|
@@ -1281,8 +1426,11 @@ export default function renderFactory() {
|
|
|
1281
1426
|
// Check if we've exceeded mismatch threshold after processing nodes
|
|
1282
1427
|
// If so, clear and re-render from scratch (client VDOM wins)
|
|
1283
1428
|
if (isHydrating && hydrationMismatchCount > MAX_HYDRATION_MISMATCHES) {
|
|
1429
|
+
const mismatchSummaries = takeHydrationMismatchSummaries()
|
|
1284
1430
|
logger.warn('hydration mismatch threshold exceeded. clearing parent and re-rendering from client vdom.', {
|
|
1285
1431
|
mismatchCount: hydrationMismatchCount,
|
|
1432
|
+
mismatches: mismatchSummaries,
|
|
1433
|
+
summariesTruncated: mismatchSummaries.length < hydrationMismatchCount,
|
|
1286
1434
|
threshold: MAX_HYDRATION_MISMATCHES,
|
|
1287
1435
|
})
|
|
1288
1436
|
dom.textContent = ''
|
package/render/ssrState.ts
CHANGED
|
@@ -239,7 +239,11 @@ export function deserializeStore(state: State<any>, serialized: any): void {
|
|
|
239
239
|
const signal = signalMap.get(key)
|
|
240
240
|
// Don't update ComputedSignal (they're read-only)
|
|
241
241
|
if (signal && !(signal instanceof ComputedSignal)) {
|
|
242
|
-
signal.value =
|
|
242
|
+
// Use the proxy setter, not `signal.value = …`. Direct assignment bypasses
|
|
243
|
+
// initializeSignals() for nested objects, so nested state (e.g. identity.user)
|
|
244
|
+
// stays plain JSON — readable as `user.first_name` but `$first_name` is missing
|
|
245
|
+
// and components like FieldText that need model refs render nothing.
|
|
246
|
+
;(state as any)[key] = deserializedValue
|
|
243
247
|
}
|
|
244
248
|
} else {
|
|
245
249
|
// Signal doesn't exist - use proxy setter to create it
|
package/server/logger.ts
CHANGED
|
@@ -55,13 +55,77 @@ function getTimestamp(): string {
|
|
|
55
55
|
function formatLevel(level: 'info' | 'debug' | 'warn' | 'error'): string {
|
|
56
56
|
const levelMap = {
|
|
57
57
|
info: colorize('info', colors.bright + colors.cyan),
|
|
58
|
-
debug: colorize('debug', colors.
|
|
58
|
+
debug: colorize('debug', colors.dim + colors.blue),
|
|
59
59
|
warn: colorize('warn', colors.bright + colors.yellow),
|
|
60
60
|
error: colorize('error', colors.bright + colors.red),
|
|
61
61
|
}
|
|
62
62
|
return levelMap[level]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function formatPrefixForServer(prefix: string): string {
|
|
66
|
+
return colorize(prefix, getColorByHash(prefix))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Simple hash function for string to generate a consistent number. */
|
|
70
|
+
function hashString(str: string): number {
|
|
71
|
+
let hash = 0
|
|
72
|
+
for (let i = 0; i < str.length; i++) {
|
|
73
|
+
const char = str.charCodeAt(i)
|
|
74
|
+
hash = (hash << 5) - hash + char
|
|
75
|
+
hash = hash & hash // Convert to 32bit integer
|
|
76
|
+
}
|
|
77
|
+
return Math.abs(hash)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Get a color based on hash of the input string. */
|
|
81
|
+
function getColorByHash(text: string): string {
|
|
82
|
+
const colorOptions = [
|
|
83
|
+
// Bright variants
|
|
84
|
+
colors.bright + colors.red,
|
|
85
|
+
colors.bright + colors.green,
|
|
86
|
+
colors.bright + colors.yellow,
|
|
87
|
+
colors.bright + colors.blue,
|
|
88
|
+
colors.bright + colors.magenta,
|
|
89
|
+
colors.bright + colors.cyan,
|
|
90
|
+
// Regular variants
|
|
91
|
+
colors.red,
|
|
92
|
+
colors.green,
|
|
93
|
+
colors.yellow,
|
|
94
|
+
colors.blue,
|
|
95
|
+
colors.magenta,
|
|
96
|
+
colors.cyan,
|
|
97
|
+
]
|
|
98
|
+
const hash = hashString(text)
|
|
99
|
+
return colorOptions[hash % colorOptions.length]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
|
|
103
|
+
|
|
104
|
+
/** UTF-8 byte length of a string (for SSR response size). */
|
|
105
|
+
export function utf8ByteLength(s: string): number {
|
|
106
|
+
return textEncoder ? textEncoder.encode(s).length : s.length
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SsrPageSummaryFields {
|
|
110
|
+
bytesHtml: number
|
|
111
|
+
bytesState: number
|
|
112
|
+
method: string
|
|
113
|
+
msTotal: number
|
|
114
|
+
pathname: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One-line SSR summary for the terminal: dim labels, bright path, yellow numbers.
|
|
119
|
+
* Use with {@link Logger.infoRaw} so the body is not flattened into dim context.
|
|
120
|
+
*/
|
|
121
|
+
export function formatSsrPageSummaryLine(fields: SsrPageSummaryFields): string {
|
|
122
|
+
const d = (s: string) => colorize(s, colors.dim + colors.white)
|
|
123
|
+
const n = (v: number) => String(Math.round(v))
|
|
124
|
+
const path = colorize(fields.pathname, colors.green)
|
|
125
|
+
const method = colorize(fields.method, colors.cyan)
|
|
126
|
+
return `${d('page')} ${method} ${path} ${n(fields.msTotal)}ms ${d('body')} ${n(fields.bytesHtml / 1024)}KB ${d('state')} ${n(fields.bytesState / 1024)}KB`
|
|
127
|
+
}
|
|
128
|
+
|
|
65
129
|
export interface LogContext {
|
|
66
130
|
pathname?: string
|
|
67
131
|
method?: string
|
|
@@ -71,6 +135,22 @@ export interface LogContext {
|
|
|
71
135
|
[key: string]: any
|
|
72
136
|
}
|
|
73
137
|
|
|
138
|
+
const RESERVED_CONTEXT_KEYS = new Set(['method', 'pathname', 'route', 'sessionId', 'module'])
|
|
139
|
+
|
|
140
|
+
/** Serialize non-primitive context for terminal logs (avoids [object Object]). */
|
|
141
|
+
function formatContextValueForServer(value: unknown): string {
|
|
142
|
+
if (value === undefined) return 'undefined'
|
|
143
|
+
if (value === null) return 'null'
|
|
144
|
+
const t = typeof value
|
|
145
|
+
if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint') return String(value)
|
|
146
|
+
if (value instanceof Error) return value.stack ?? value.message
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(value)
|
|
149
|
+
} catch {
|
|
150
|
+
return String(value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
74
154
|
class Logger {
|
|
75
155
|
// Default prefix: [ssr] for server infrastructure, [app] for application code
|
|
76
156
|
private prefix: string = '[ssr]'
|
|
@@ -86,38 +166,42 @@ class Logger {
|
|
|
86
166
|
const timestamp = colorize(getTimestamp(), colors.dim + colors.white)
|
|
87
167
|
const levelStr = formatLevel(level)
|
|
88
168
|
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
this.prefix,
|
|
92
|
-
this.prefix === '[ssr]' ? colors.bright + colors.magenta : colors.bright + colors.cyan,
|
|
93
|
-
)
|
|
169
|
+
const prefixStr = formatPrefixForServer(this.prefix)
|
|
170
|
+
const isDebug = level === 'debug'
|
|
94
171
|
|
|
95
172
|
// Include module in message if provided
|
|
96
173
|
let displayMessage = message
|
|
97
174
|
if (context?.module) {
|
|
98
175
|
displayMessage = `[${context.module}] ${message}`
|
|
99
176
|
}
|
|
177
|
+
if (isDebug) {
|
|
178
|
+
displayMessage = colorize(displayMessage, colors.dim + colors.white)
|
|
179
|
+
}
|
|
100
180
|
|
|
101
181
|
let contextStr = ''
|
|
102
182
|
if (context) {
|
|
103
183
|
const contextParts: string[] = []
|
|
184
|
+
const methodColor = isDebug ? colors.dim + colors.cyan : colors.cyan
|
|
185
|
+
const pathColor = isDebug ? colors.dim + colors.green : colors.green
|
|
186
|
+
const routeColor = isDebug ? colors.dim + colors.blue : colors.blue
|
|
187
|
+
const extraColor = isDebug ? colors.dim + colors.blue : colors.dim + colors.white
|
|
104
188
|
if (context.method) {
|
|
105
|
-
contextParts.push(colorize(context.method,
|
|
189
|
+
contextParts.push(colorize(context.method, methodColor))
|
|
106
190
|
}
|
|
107
191
|
if (context.pathname) {
|
|
108
|
-
contextParts.push(colorize(context.pathname,
|
|
192
|
+
contextParts.push(colorize(context.pathname, pathColor))
|
|
109
193
|
}
|
|
110
194
|
if (context.route) {
|
|
111
|
-
contextParts.push(colorize(`route:${context.route}`,
|
|
195
|
+
contextParts.push(colorize(`route:${context.route}`, routeColor))
|
|
112
196
|
}
|
|
113
197
|
if (context.sessionId) {
|
|
114
|
-
contextParts.push(colorize(`session:${context.sessionId.slice(0, 8)}...`,
|
|
198
|
+
contextParts.push(colorize(`session:${context.sessionId.slice(0, 8)}...`, extraColor))
|
|
115
199
|
}
|
|
116
200
|
|
|
117
201
|
// Add any additional context fields (excluding module which is shown in message)
|
|
118
202
|
for (const [key, value] of Object.entries(context)) {
|
|
119
|
-
if (!
|
|
120
|
-
contextParts.push(colorize(`${key}:${value}`,
|
|
203
|
+
if (!RESERVED_CONTEXT_KEYS.has(key)) {
|
|
204
|
+
contextParts.push(colorize(`${key}:${formatContextValueForServer(value)}`, extraColor))
|
|
121
205
|
}
|
|
122
206
|
}
|
|
123
207
|
|
|
@@ -129,19 +213,25 @@ class Logger {
|
|
|
129
213
|
return `${timestamp} ${prefixStr} ${levelStr}${contextStr} ${displayMessage}`
|
|
130
214
|
}
|
|
131
215
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (context.
|
|
138
|
-
if (context.
|
|
216
|
+
/**
|
|
217
|
+
* Emit context in DevTools-friendly form: objects/arrays are passed as separate arguments so
|
|
218
|
+
* they stay expandable instead of becoming "[object Object]" strings.
|
|
219
|
+
*/
|
|
220
|
+
private logBrowserContext(logFn: typeof console.log, context: LogContext): void {
|
|
221
|
+
if (context.method != null) logFn(' method:', context.method)
|
|
222
|
+
if (context.pathname != null) logFn(' pathname:', context.pathname)
|
|
223
|
+
if (context.route != null) logFn(' route:', context.route)
|
|
224
|
+
if (context.sessionId != null) logFn(' sessionId:', `${context.sessionId.slice(0, 8)}...`)
|
|
225
|
+
|
|
139
226
|
for (const [key, value] of Object.entries(context)) {
|
|
140
|
-
if (
|
|
141
|
-
|
|
227
|
+
if (RESERVED_CONTEXT_KEYS.has(key)) continue
|
|
228
|
+
const label = ` ${key}:`
|
|
229
|
+
if (value !== null && typeof value === 'object') {
|
|
230
|
+
logFn(label, value)
|
|
231
|
+
} else {
|
|
232
|
+
logFn(label, value as string | number | boolean | undefined)
|
|
142
233
|
}
|
|
143
234
|
}
|
|
144
|
-
return parts
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
private logBrowser(
|
|
@@ -151,24 +241,27 @@ class Logger {
|
|
|
151
241
|
error?: Error | unknown,
|
|
152
242
|
): void {
|
|
153
243
|
const displayMessage = context?.module ? `[${context.module}] ${message}` : message
|
|
154
|
-
const prefixStyle = this.prefix === '[ssr]' ? 'color: #d946ef; font-weight: bold' : 'color: #
|
|
244
|
+
const prefixStyle = this.prefix === '[ssr]' ? 'color: #d946ef; font-weight: bold' : 'color: #64748b; font-weight: normal'
|
|
155
245
|
const levelStyles: Record<string, string> = {
|
|
156
246
|
info: 'color: #22d3ee; font-weight: bold',
|
|
157
|
-
debug: 'color: #
|
|
247
|
+
debug: 'color: #64748b; font-weight: normal',
|
|
158
248
|
warn: 'color: #fbbf24; font-weight: bold',
|
|
159
249
|
error: 'color: #ef4444; font-weight: bold',
|
|
160
250
|
}
|
|
251
|
+
const bodyStyle = level === 'debug' ? 'color: #94a3b8; font-weight: normal' : 'color: inherit'
|
|
161
252
|
const logFn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
console.group(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level],
|
|
165
|
-
|
|
253
|
+
const hasContext = context != null && Object.keys(context).length > 0
|
|
254
|
+
if (hasContext || error) {
|
|
255
|
+
console.group(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level], bodyStyle)
|
|
256
|
+
if (context && hasContext) {
|
|
257
|
+
this.logBrowserContext(logFn, context)
|
|
258
|
+
}
|
|
166
259
|
if (error instanceof Error && error.stack) {
|
|
167
260
|
console.error('Stack trace:', error.stack)
|
|
168
261
|
}
|
|
169
262
|
console.groupEnd()
|
|
170
263
|
} else {
|
|
171
|
-
logFn(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level],
|
|
264
|
+
logFn(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level], bodyStyle)
|
|
172
265
|
}
|
|
173
266
|
}
|
|
174
267
|
|
|
@@ -180,6 +273,20 @@ class Logger {
|
|
|
180
273
|
}
|
|
181
274
|
}
|
|
182
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Server only: log one info line whose body may already contain ANSI (e.g. {@link formatSsrPageSummaryLine}).
|
|
278
|
+
*/
|
|
279
|
+
infoRaw(messageBody: string): void {
|
|
280
|
+
if (isBrowser) {
|
|
281
|
+
console.log(`${this.prefix} info ${messageBody}`)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const timestamp = colorize(getTimestamp(), colors.dim + colors.white)
|
|
285
|
+
const levelStr = formatLevel('info')
|
|
286
|
+
const prefixStr = formatPrefixForServer(this.prefix)
|
|
287
|
+
console.log(`${timestamp} ${prefixStr} ${levelStr} ${messageBody}`)
|
|
288
|
+
}
|
|
289
|
+
|
|
183
290
|
debug(message: string, context?: LogContext): void {
|
|
184
291
|
const shouldLog =
|
|
185
292
|
globalThis.__SSR_MODE__ || (isBrowser && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production')
|
package/server/ssr.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {readFile} from 'fs/promises'
|
|
|
3
3
|
import m from '../server'
|
|
4
4
|
import {runWithContextAsync} from '../ssrContext'
|
|
5
5
|
|
|
6
|
+
import {formatSsrPageSummaryLine, utf8ByteLength} from './logger'
|
|
6
7
|
import {extractSessionId} from './session'
|
|
7
8
|
import {logger} from './ssrLogger'
|
|
8
9
|
|
|
@@ -10,14 +11,16 @@ import type {SessionStore} from './session'
|
|
|
10
11
|
import type {SSRAccessContext} from '../ssrContext'
|
|
11
12
|
|
|
12
13
|
declare global {
|
|
13
|
-
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
14
14
|
var __SSR_MODE__: boolean | undefined
|
|
15
|
-
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
16
15
|
var __SSR_URL__: string | undefined
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
globalThis.__SSR_MODE__ = true
|
|
20
19
|
|
|
20
|
+
function ssrMetricsBreakdownEnabled(): boolean {
|
|
21
|
+
return process.env.MITHRIL_SSR_METRICS === '1'
|
|
22
|
+
}
|
|
23
|
+
|
|
21
24
|
export interface SSROptions {
|
|
22
25
|
routes: Record<string, any>
|
|
23
26
|
/** Create per-request context (store, stateRegistry, sessionId, sessionData). */
|
|
@@ -47,9 +50,9 @@ export async function getBunProcessedTemplate(
|
|
|
47
50
|
): Promise<string> {
|
|
48
51
|
// Fetch from Bun's route handler to get processed template with HMR scripts
|
|
49
52
|
// Use a special route that Bun processes but we don't use for SSR
|
|
50
|
-
const
|
|
53
|
+
const template_url = `http://localhost:${port}${templateRoute}`
|
|
51
54
|
try {
|
|
52
|
-
const response = await fetch(
|
|
55
|
+
const response = await fetch(template_url)
|
|
53
56
|
if (response.ok) {
|
|
54
57
|
return await response.text()
|
|
55
58
|
}
|
|
@@ -91,28 +94,28 @@ export async function createSSRResponse(pathname: string, req: Request, options:
|
|
|
91
94
|
|
|
92
95
|
return runWithContextAsync(context, async () => {
|
|
93
96
|
try {
|
|
94
|
-
await options.initRequestContext(context)
|
|
95
|
-
|
|
96
97
|
globalThis.__SSR_URL__ = req.url
|
|
97
98
|
|
|
98
|
-
const
|
|
99
|
-
|
|
99
|
+
const t0 = performance.now()
|
|
100
|
+
await options.initRequestContext(context)
|
|
101
|
+
const t1 = performance.now()
|
|
100
102
|
|
|
101
103
|
const result = await m.route.resolve(pathname, options.routes, m.renderToString)
|
|
104
|
+
const t2 = performance.now()
|
|
102
105
|
|
|
103
106
|
const appHtml = typeof result === 'string' ? result : result.html
|
|
104
|
-
const
|
|
105
|
-
const
|
|
107
|
+
const baseSerializedState = typeof result === 'string' ? {} : result.state
|
|
108
|
+
const meta = context.ssrStateMeta
|
|
109
|
+
const serializedState =
|
|
110
|
+
meta && Object.keys(meta).length > 0 ? {...baseSerializedState, __meta: meta} : baseSerializedState
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!appHtml || appHtml.trim() === '' || appHtml.trim() === '<div></div>') {
|
|
112
|
+
const emptyHtml = !appHtml || appHtml.trim() === '' || appHtml.trim() === '<div></div>'
|
|
113
|
+
if (emptyHtml) {
|
|
110
114
|
logger.warn('empty html rendered', {pathname})
|
|
111
|
-
} else {
|
|
112
|
-
logger.info(`get ${pathname} → ${htmlLength} chars`)
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
let html = await options.getHtmlTemplate()
|
|
118
|
+
const t3 = performance.now()
|
|
116
119
|
|
|
117
120
|
const appSelector = options.appSelector || '#app'
|
|
118
121
|
|
|
@@ -135,16 +138,43 @@ export async function createSSRResponse(pathname: string, req: Request, options:
|
|
|
135
138
|
})
|
|
136
139
|
|
|
137
140
|
const stateScriptId = options.stateScriptId || '__SSR_STATE__'
|
|
138
|
-
const
|
|
141
|
+
const stateJson = JSON.stringify(serializedState)
|
|
142
|
+
const stateScript = `<script id="${stateScriptId}" type="application/json">${stateJson}</script>`
|
|
139
143
|
html = html.replace('</head>', `${stateScript}</head>`)
|
|
140
144
|
|
|
145
|
+
const t4 = performance.now()
|
|
146
|
+
|
|
147
|
+
if (!emptyHtml) {
|
|
148
|
+
const msTotal = t4 - t0
|
|
149
|
+
const bytesHtml = utf8ByteLength(html)
|
|
150
|
+
const bytesState = utf8ByteLength(stateJson)
|
|
151
|
+
logger.infoRaw(
|
|
152
|
+
formatSsrPageSummaryLine({
|
|
153
|
+
bytesHtml,
|
|
154
|
+
bytesState,
|
|
155
|
+
method: req.method,
|
|
156
|
+
msTotal,
|
|
157
|
+
pathname,
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
if (ssrMetricsBreakdownEnabled()) {
|
|
161
|
+
logger.debug('ssr phases', {
|
|
162
|
+
ms_init_context: Math.round((t1 - t0) * 100) / 100,
|
|
163
|
+
ms_route_render: Math.round((t2 - t1) * 100) / 100,
|
|
164
|
+
ms_template_fetch: Math.round((t3 - t2) * 100) / 100,
|
|
165
|
+
ms_template_merge: Math.round((t4 - t3) * 100) / 100,
|
|
166
|
+
pathname,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
const sessionId = context.sessionId ?? ''
|
|
142
172
|
|
|
143
173
|
return new Response(html, {
|
|
144
174
|
headers: {
|
|
145
|
-
//
|
|
175
|
+
//
|
|
146
176
|
'Content-Type': 'text/html; charset=utf-8',
|
|
147
|
-
//
|
|
177
|
+
//
|
|
148
178
|
'Set-Cookie': `sessionId=${sessionId}; Path=/; HttpOnly; SameSite=Lax`,
|
|
149
179
|
},
|
|
150
180
|
})
|
|
@@ -160,7 +190,7 @@ export async function createSSRResponse(pathname: string, req: Request, options:
|
|
|
160
190
|
}
|
|
161
191
|
|
|
162
192
|
/**
|
|
163
|
-
* Create
|
|
193
|
+
* Create se
|
|
164
194
|
* Returns a handler function for POST /api/session requests
|
|
165
195
|
*/
|
|
166
196
|
export function createSessionUpdateHandler(
|
|
@@ -191,7 +221,6 @@ export function createSessionUpdateHandler(
|
|
|
191
221
|
})
|
|
192
222
|
|
|
193
223
|
return new Response(JSON.stringify({success: true}), {
|
|
194
|
-
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
195
224
|
headers: {'Content-Type': 'application/json'},
|
|
196
225
|
})
|
|
197
226
|
} catch (error) {
|
package/ssrContext.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface SSRAccessContext {
|
|
|
35
35
|
sessionData?: any
|
|
36
36
|
/** Per-request EventEmitter; prevents event listeners from persisting between requests. */
|
|
37
37
|
events?: any
|
|
38
|
+
/**
|
|
39
|
+
* Optional metadata merged into `#__SSR_STATE__` as top-level `__meta` when serializing.
|
|
40
|
+
* Consumers should strip `__meta` before `deserializeAllStates`. Set during SSR app init.
|
|
41
|
+
*/
|
|
42
|
+
ssrStateMeta?: Record<string, unknown>
|
|
38
43
|
/** Per-request watcher cleanup functions; prevents watchers from persisting between requests. */
|
|
39
44
|
watchers?: Array<() => void>
|
|
40
45
|
}
|
package/state.ts
CHANGED
|
@@ -756,6 +756,8 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
|
|
|
756
756
|
} else {
|
|
757
757
|
// Create new signal (new key added to object)
|
|
758
758
|
nestedSignalMap.set(key, createPropertySignal(value))
|
|
759
|
+
// Mirror the key on the target so devtools show it when expanding <target>
|
|
760
|
+
Reflect.set(target, prop, value)
|
|
759
761
|
// Notify parent so subscribers see the key addition
|
|
760
762
|
const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
|
|
761
763
|
if (parentSignal && typeof (parentSignal as any).trigger === 'function') {
|
|
@@ -799,6 +801,7 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
|
|
|
799
801
|
return {
|
|
800
802
|
enumerable: true,
|
|
801
803
|
configurable: true,
|
|
804
|
+
writable: true,
|
|
802
805
|
}
|
|
803
806
|
}
|
|
804
807
|
return Reflect.getOwnPropertyDescriptor(target, prop)
|
|
@@ -865,19 +868,20 @@ export type StateSignals<T extends Record<string, any>> = {
|
|
|
865
868
|
? Signal<State<T[K]>>
|
|
866
869
|
: Signal<T[K]>
|
|
867
870
|
}
|
|
871
|
+
type StateElem<E> = E extends Record<string, any> ? State<E> : E
|
|
868
872
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
} & StateSignals<T>
|
|
873
|
+
export type StateArray<Elem> = Omit<Array<StateElem<Elem>>, 'fill' | 'push' | 'splice' | 'unshift'> & {
|
|
874
|
+
fill(value: Elem, start?: number, end?: number): StateArray<Elem>
|
|
875
|
+
push(...items: Elem[]): number
|
|
876
|
+
splice(start: number, deleteCount?: number, ...items: Elem[]): StateElem<Elem>[]
|
|
877
|
+
unshift(...items: Elem[]): number
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export type State<T extends Record<string, any>> = T extends (infer Elem)[]
|
|
881
|
+
? StateArray<Elem>
|
|
882
|
+
: {
|
|
883
|
+
[K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : T[K] extends Record<string, any> ? State<T[K]> : T[K]
|
|
884
|
+
} & StateSignals<T>
|
|
881
885
|
|
|
882
886
|
/** Function returned by watch() to remove the watcher */
|
|
883
887
|
export type Unwatch = () => void
|
package/store.ts
CHANGED
|
@@ -118,6 +118,10 @@ function merge_deep(target: any, ...sources: any[]): any {
|
|
|
118
118
|
|
|
119
119
|
const DEFAULT_LOOKUP_VERIFY_INTERVAL = 1000 * 10 // 10 seconds
|
|
120
120
|
const DEFAULT_LOOKUP_TTL = 1000 * 60 * 60 * 24 // 1 day
|
|
121
|
+
const DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year, in seconds
|
|
122
|
+
// Browsers cap a single cookie at ~4KB; stay well under so we never silently drop a write
|
|
123
|
+
// or bloat every request. The cookie tier is for small, render-affecting preferences only.
|
|
124
|
+
const MAX_COOKIE_BYTES = 3500
|
|
121
125
|
|
|
122
126
|
// Counter for generating unique store instance names
|
|
123
127
|
let storeInstanceCounter = 0
|
|
@@ -131,6 +135,10 @@ let storeInstanceCounter = 0
|
|
|
131
135
|
* - temporary: not persisted (resets on reload)
|
|
132
136
|
* - tab: sessionStorage (survives page reloads, clears when tab closes)
|
|
133
137
|
* - session: server-side session storage (optional, off by default; requires backend, hydrated via SSR)
|
|
138
|
+
* - cookie: a single JSON cookie (optional, off by default). Unlike localStorage, a cookie is sent
|
|
139
|
+
* on the SSR document request, so the server can render with these values and avoid a hydration
|
|
140
|
+
* flash. For small, render-affecting preferences only (≤MAX_COOKIE_BYTES) — never large or
|
|
141
|
+
* growing data (it ships on every request).
|
|
134
142
|
*/
|
|
135
143
|
export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
136
144
|
private stateInstance: State<T>
|
|
@@ -139,17 +147,30 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
139
147
|
temporary: {} as Partial<T>,
|
|
140
148
|
tab: {} as Partial<T>,
|
|
141
149
|
session: {} as Partial<T>,
|
|
150
|
+
cookie: {} as Partial<T>,
|
|
142
151
|
}
|
|
143
152
|
private lookup_verify_interval: number | null = null
|
|
144
153
|
private lookup_ttl: number
|
|
145
154
|
private computedPropertiesSetup?: () => void
|
|
146
155
|
private storageKey: string
|
|
147
156
|
private tabStorageKey: string
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
private cookieKey: string
|
|
158
|
+
private cookieMaxAge: number
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
options: {
|
|
162
|
+
lookup_ttl?: number
|
|
163
|
+
storageKey?: string
|
|
164
|
+
tabStorageKey?: string
|
|
165
|
+
cookieKey?: string
|
|
166
|
+
cookieMaxAge?: number
|
|
167
|
+
} = {lookup_ttl: DEFAULT_LOOKUP_TTL},
|
|
168
|
+
) {
|
|
150
169
|
this.lookup_ttl = options.lookup_ttl || DEFAULT_LOOKUP_TTL
|
|
151
170
|
this.storageKey = options.storageKey ?? 'store'
|
|
152
171
|
this.tabStorageKey = options.tabStorageKey ?? this.storageKey
|
|
172
|
+
this.cookieKey = options.cookieKey ?? 'store_prefs'
|
|
173
|
+
this.cookieMaxAge = options.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE
|
|
153
174
|
// Initialize with empty state, will be loaded later (ADR-0013: defer computeds until ready() is called)
|
|
154
175
|
const instanceName = `store.instance.${storeInstanceCounter++}`
|
|
155
176
|
this.stateInstance = state({} as T, instanceName, {deferComputed: true})
|
|
@@ -184,23 +205,26 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
184
205
|
}
|
|
185
206
|
const result: any = {}
|
|
186
207
|
for (const key of Object.keys(blueprint)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
// Use `in` so Mithril state proxies (signal-backed roots) are not skipped; `hasOwnProperty`
|
|
209
|
+
// can be false for keys that only exist on the proxy’s `has` / signal map.
|
|
210
|
+
if (!(key in (state as object))) {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const blueprintValue = (blueprint as any)[key]
|
|
214
|
+
const stateValue = (state as any)[key]
|
|
215
|
+
if (!Array.isArray(blueprintValue) && blueprintValue !== null && is_object(blueprintValue)) {
|
|
216
|
+
// (!) Convention: The contents of a state key with the name 'lookup' is
|
|
217
|
+
// always one-one copied from the state, instead of being
|
|
218
|
+
// blueprinted per-key. This is to accomodate key/value
|
|
219
|
+
// lookups, without having to define each key in the
|
|
220
|
+
// state's persistent section.
|
|
221
|
+
if (key === 'lookup') {
|
|
222
|
+
result[key] = copy_object(stateValue)
|
|
201
223
|
} else {
|
|
202
|
-
result[key] = stateValue
|
|
224
|
+
result[key] = this.blueprint(stateValue, blueprintValue)
|
|
203
225
|
}
|
|
226
|
+
} else {
|
|
227
|
+
result[key] = stateValue
|
|
204
228
|
}
|
|
205
229
|
}
|
|
206
230
|
return result as Partial<T>
|
|
@@ -272,7 +296,13 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
272
296
|
}
|
|
273
297
|
}
|
|
274
298
|
|
|
275
|
-
load(
|
|
299
|
+
load(
|
|
300
|
+
saved: Partial<T>,
|
|
301
|
+
temporary: Partial<T>,
|
|
302
|
+
tab: Partial<T> = {} as Partial<T>,
|
|
303
|
+
session: Partial<T> = {} as Partial<T>,
|
|
304
|
+
cookie: Partial<T> = {} as Partial<T>,
|
|
305
|
+
) {
|
|
276
306
|
const restored_state = {
|
|
277
307
|
tab: this.get_tab_storage(this.tabStorageKey),
|
|
278
308
|
store: this.get(this.storageKey),
|
|
@@ -283,6 +313,7 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
283
313
|
temporary,
|
|
284
314
|
tab,
|
|
285
315
|
session,
|
|
316
|
+
cookie,
|
|
286
317
|
}
|
|
287
318
|
|
|
288
319
|
try {
|
|
@@ -303,7 +334,6 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
303
334
|
console.log('[store] loading tab state from local store')
|
|
304
335
|
tab_state = merge_deep(copy_object(this.templates.tab), store_state.tab)
|
|
305
336
|
} else {
|
|
306
|
-
console.log('[store] restoring existing tab state')
|
|
307
337
|
tab_state = merge_deep(copy_object(this.templates.tab), copy_object(restored_state.tab))
|
|
308
338
|
}
|
|
309
339
|
|
|
@@ -319,6 +349,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
319
349
|
// Session state comes from server (SSR), not localStorage
|
|
320
350
|
const final_state = merge_deep(temp_state, copy_object(session))
|
|
321
351
|
|
|
352
|
+
// Merge cookie state last so it wins for its keys. On the client we read the actual cookie
|
|
353
|
+
// from document.cookie; during SSR there is no document, so the caller passes the
|
|
354
|
+
// request-derived values as the `cookie` template (same pattern as session/sessionTemplate).
|
|
355
|
+
const cookie_state = merge_deep(copy_object(cookie), this.get_cookie(this.cookieKey))
|
|
356
|
+
merge_deep(final_state, cookie_state)
|
|
357
|
+
|
|
322
358
|
// Merge templates (including computed properties) into "merged initial state"
|
|
323
359
|
// This will be stored in registry so computed properties can be automatically restored
|
|
324
360
|
// Use copy_object_preserve_functions to deep copy while preserving functions
|
|
@@ -329,7 +365,15 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
329
365
|
const mergedInitialTab = tab && Object.keys(tab).length > 0 ? {tab: copy_object_preserve_functions(tab)} : {}
|
|
330
366
|
// Session template is merged directly (no nesting needed, structure matches final_state)
|
|
331
367
|
const mergedInitialSession = copy_object_preserve_functions(session)
|
|
332
|
-
|
|
368
|
+
// Cookie template is merged directly too (structure matches final_state)
|
|
369
|
+
const mergedInitialCookie = copy_object_preserve_functions(cookie)
|
|
370
|
+
const mergedInitial = merge_deep(
|
|
371
|
+
mergedInitialSaved,
|
|
372
|
+
mergedInitialTemporary,
|
|
373
|
+
mergedInitialTab,
|
|
374
|
+
mergedInitialSession,
|
|
375
|
+
mergedInitialCookie,
|
|
376
|
+
)
|
|
333
377
|
|
|
334
378
|
// Update registry entry to store merged templates as "initial" state
|
|
335
379
|
// This allows deserializeAllStates() to automatically restore computed properties
|
|
@@ -363,19 +407,21 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
363
407
|
}
|
|
364
408
|
|
|
365
409
|
/**
|
|
366
|
-
* Persist state to storage. When no options are passed, saves to localStorage (saved)
|
|
367
|
-
* sessionStorage (tab)
|
|
410
|
+
* Persist state to storage. When no options are passed, saves to localStorage (saved),
|
|
411
|
+
* sessionStorage (tab), and the cookie tier (when a cookie template is registered). Session
|
|
412
|
+
* (server-side) is off by default; pass { session: true } to persist it.
|
|
368
413
|
*/
|
|
369
|
-
async save(options?: {saved?: boolean; tab?: boolean; session?: boolean}): Promise<void> {
|
|
414
|
+
async save(options?: {saved?: boolean; tab?: boolean; session?: boolean; cookie?: boolean}): Promise<void> {
|
|
370
415
|
// Skip saving during SSR (server-side rendering in Bun)
|
|
371
416
|
// On the server, there's no localStorage/sessionStorage and no need to persist state
|
|
372
417
|
if (globalThis.__SSR_MODE__) {
|
|
373
418
|
return
|
|
374
419
|
}
|
|
375
420
|
|
|
376
|
-
// Default: write to localStorage and
|
|
421
|
+
// Default: write to localStorage, sessionStorage and cookie when no options; session is opt-in
|
|
377
422
|
const writeLocalStorage = options?.saved ?? options === undefined
|
|
378
423
|
const writeSessionStorage = options?.tab ?? options === undefined
|
|
424
|
+
const writeCookie = options?.cookie ?? options === undefined
|
|
379
425
|
const writeSessionApi = options?.session === true
|
|
380
426
|
|
|
381
427
|
const statePlain = serializeStore(this.stateInstance)
|
|
@@ -391,6 +437,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
391
437
|
this.persist_lookup_to_local_storage(statePlain)
|
|
392
438
|
}
|
|
393
439
|
|
|
440
|
+
// Write cookie-backed preferences (small, SSR-visible). Only when a cookie template is
|
|
441
|
+
// registered, so consumers that don't opt in never get an empty cookie written.
|
|
442
|
+
if (writeCookie && this.templates.cookie && Object.keys(this.templates.cookie).length > 0) {
|
|
443
|
+
this.set_cookie(this.cookieKey, this.blueprint(statePlain, copy_object(this.templates.cookie)))
|
|
444
|
+
}
|
|
445
|
+
|
|
394
446
|
// Write to sessionStorage (tab-scoped, cleared when tab closes)
|
|
395
447
|
if (writeSessionStorage && this.templates.tab) {
|
|
396
448
|
const tabState = (this.stateInstance as any).tab
|
|
@@ -474,6 +526,43 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
474
526
|
}
|
|
475
527
|
}
|
|
476
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Read and parse the JSON cookie tier. Returns {} when there is no document (SSR), the cookie
|
|
531
|
+
* is absent, or it cannot be parsed. During SSR the request cookie is injected via the `cookie`
|
|
532
|
+
* template in load() instead, so this only does real work in the browser.
|
|
533
|
+
*/
|
|
534
|
+
get_cookie(key: string): Record<string, any> {
|
|
535
|
+
if (typeof document === 'undefined') return {}
|
|
536
|
+
try {
|
|
537
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`))
|
|
538
|
+
if (!match) return {}
|
|
539
|
+
const parsed = JSON.parse(decodeURIComponent(match[1]))
|
|
540
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
541
|
+
} catch {
|
|
542
|
+
return {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Write the cookie tier as a single JSON cookie. Skips the write (with a warning) when the
|
|
548
|
+
* serialized value would exceed MAX_COOKIE_BYTES, so we never silently corrupt requests.
|
|
549
|
+
*/
|
|
550
|
+
set_cookie(key: string, item: object): void {
|
|
551
|
+
if (typeof document === 'undefined') return
|
|
552
|
+
try {
|
|
553
|
+
const value = encodeURIComponent(JSON.stringify(item))
|
|
554
|
+
if (value.length > MAX_COOKIE_BYTES) {
|
|
555
|
+
console.warn(
|
|
556
|
+
`[store] cookie '${key}' is ${value.length} bytes, over the ${MAX_COOKIE_BYTES} limit; skipping write. Keep the cookie tier small.`,
|
|
557
|
+
)
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
document.cookie = `${key}=${value}; Path=/; SameSite=Lax; Max-Age=${this.cookieMaxAge}`
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error('Cannot write cookie; continue without.', err)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
477
566
|
set_tab(key: string, item: object): void {
|
|
478
567
|
if (typeof window === 'undefined') return
|
|
479
568
|
try {
|
package/util/ssr.ts
CHANGED
|
@@ -9,9 +9,20 @@ export const HYDRATION_DEBUG = typeof process !== 'undefined' && process.env?.NO
|
|
|
9
9
|
let hydrationErrorCount = 0
|
|
10
10
|
const MAX_HYDRATION_ERRORS = 10 // Limit number of errors logged per render cycle
|
|
11
11
|
|
|
12
|
+
export interface HydrationMismatchSummary {
|
|
13
|
+
kind: 'unmatched_dom_child_removed' | 'remove_child_failed'
|
|
14
|
+
parentDom: string
|
|
15
|
+
parentVnode: string
|
|
16
|
+
removed: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const hydrationMismatchSummaries: HydrationMismatchSummary[] = []
|
|
20
|
+
const MAX_HYDRATION_MISMATCH_SUMMARIES = 32
|
|
21
|
+
|
|
12
22
|
// Reset error count at the start of each render cycle
|
|
13
23
|
export function resetHydrationErrorCount(): void {
|
|
14
24
|
hydrationErrorCount = 0
|
|
25
|
+
hydrationMismatchSummaries.length = 0
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export function getComponentName(vnode: any): string {
|
|
@@ -237,7 +248,68 @@ function buildComponentPath(vnode: any, context?: {oldVnode?: any; newVnode?: an
|
|
|
237
248
|
return path
|
|
238
249
|
}
|
|
239
250
|
|
|
240
|
-
|
|
251
|
+
/** Short label for a DOM node during hydration debug (tag, text preview, or node type). */
|
|
252
|
+
export function describeHydrationDomNode(node: Node, textPreviewMax = 80): string {
|
|
253
|
+
if (node.nodeType === 3) {
|
|
254
|
+
const raw = (node as Text).nodeValue ?? ''
|
|
255
|
+
const normalized = raw.replace(/\s+/g, ' ').trim()
|
|
256
|
+
const excerpt = normalized.length > textPreviewMax ? `${normalized.slice(0, textPreviewMax)}…` : normalized
|
|
257
|
+
return `text "${excerpt}"`
|
|
258
|
+
}
|
|
259
|
+
if (node.nodeType === 1) {
|
|
260
|
+
const el = node as Element
|
|
261
|
+
const tag = el.tagName.toLowerCase()
|
|
262
|
+
const id = el.id ? `#${el.id}` : ''
|
|
263
|
+
let cls = ''
|
|
264
|
+
if (el.className && typeof el.className === 'string') {
|
|
265
|
+
const parts = el.className.split(/\s+/).filter(Boolean).slice(0, 4)
|
|
266
|
+
if (parts.length) cls = `.${parts.join('.')}`
|
|
267
|
+
}
|
|
268
|
+
return `<${tag}${id}${cls}>`
|
|
269
|
+
}
|
|
270
|
+
if (node.nodeType === 8) {
|
|
271
|
+
const c = (node as Comment).data?.replace(/\s+/g, ' ').trim() ?? ''
|
|
272
|
+
const excerpt = c.length > textPreviewMax ? `${c.slice(0, textPreviewMax)}…` : c
|
|
273
|
+
return `comment "${excerpt}"`
|
|
274
|
+
}
|
|
275
|
+
return `${node.nodeName}[nodeType=${node.nodeType}]`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Opening-tag style summary for a parent element (hydration debug). */
|
|
279
|
+
export function describeHydrationParentElement(el: Element): string {
|
|
280
|
+
const {openTag} = formatDOMElement(el)
|
|
281
|
+
return openTag
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function recordHydrationMismatchSummary(
|
|
285
|
+
kind: HydrationMismatchSummary['kind'],
|
|
286
|
+
parentVnode: any,
|
|
287
|
+
parentEl: Element,
|
|
288
|
+
removed: Node,
|
|
289
|
+
updateStats: boolean,
|
|
290
|
+
): void {
|
|
291
|
+
if (updateStats) {
|
|
292
|
+
updateHydrationStats(parentVnode)
|
|
293
|
+
}
|
|
294
|
+
if (hydrationMismatchSummaries.length >= MAX_HYDRATION_MISMATCH_SUMMARIES) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
hydrationMismatchSummaries.push({
|
|
298
|
+
kind,
|
|
299
|
+
parentDom: describeHydrationParentElement(parentEl),
|
|
300
|
+
parentVnode: formatComponentHierarchy(parentVnode),
|
|
301
|
+
removed: describeHydrationDomNode(removed),
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Returns buffered mismatch summaries from the current render and clears the buffer. */
|
|
306
|
+
export function takeHydrationMismatchSummaries(): HydrationMismatchSummary[] {
|
|
307
|
+
const out = hydrationMismatchSummaries.slice()
|
|
308
|
+
hydrationMismatchSummaries.length = 0
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function formatComponentHierarchy(vnode: any, context?: {oldVnode?: any; newVnode?: any}): string {
|
|
241
313
|
if (!vnode) return 'Unknown'
|
|
242
314
|
|
|
243
315
|
const path = buildComponentPath(vnode, context)
|