@bitstillery/mithril 3.4.2 → 3.6.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 +18 -16
- 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
|
@@ -184,23 +184,26 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
184
184
|
}
|
|
185
185
|
const result: any = {}
|
|
186
186
|
for (const key of Object.keys(blueprint)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
187
|
+
// Use `in` so Mithril state proxies (signal-backed roots) are not skipped; `hasOwnProperty`
|
|
188
|
+
// can be false for keys that only exist on the proxy’s `has` / signal map.
|
|
189
|
+
if (!(key in (state as object))) {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const blueprintValue = (blueprint as any)[key]
|
|
193
|
+
const stateValue = (state as any)[key]
|
|
194
|
+
if (!Array.isArray(blueprintValue) && blueprintValue !== null && is_object(blueprintValue)) {
|
|
195
|
+
// (!) Convention: The contents of a state key with the name 'lookup' is
|
|
196
|
+
// always one-one copied from the state, instead of being
|
|
197
|
+
// blueprinted per-key. This is to accomodate key/value
|
|
198
|
+
// lookups, without having to define each key in the
|
|
199
|
+
// state's persistent section.
|
|
200
|
+
if (key === 'lookup') {
|
|
201
|
+
result[key] = copy_object(stateValue)
|
|
201
202
|
} else {
|
|
202
|
-
result[key] = stateValue
|
|
203
|
+
result[key] = this.blueprint(stateValue, blueprintValue)
|
|
203
204
|
}
|
|
205
|
+
} else {
|
|
206
|
+
result[key] = stateValue
|
|
204
207
|
}
|
|
205
208
|
}
|
|
206
209
|
return result as Partial<T>
|
|
@@ -303,7 +306,6 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
|
303
306
|
console.log('[store] loading tab state from local store')
|
|
304
307
|
tab_state = merge_deep(copy_object(this.templates.tab), store_state.tab)
|
|
305
308
|
} else {
|
|
306
|
-
console.log('[store] restoring existing tab state')
|
|
307
309
|
tab_state = merge_deep(copy_object(this.templates.tab), copy_object(restored_state.tab))
|
|
308
310
|
}
|
|
309
311
|
|
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)
|