@bitstillery/mithril 3.4.1 → 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 CHANGED
@@ -53,7 +53,7 @@ interface MountRedraw {
53
53
  }
54
54
 
55
55
  interface RouteOptions {
56
- /** When true, forces the route component to remount (e.g. for same-route navigation with different query params) */
56
+ /** When true, bumps remountNonce once so the resolver fragment key changes for that navigation (e.g. Link). */
57
57
  remount?: boolean
58
58
  replace?: boolean
59
59
  state?: any
@@ -80,8 +80,8 @@ export default function router($window: any, mountRedraw: MountRedraw) {
80
80
  let currentPath: string | undefined
81
81
  let lastUpdate: ((comp: any) => void) | null = null
82
82
  let routeSetResolve: (() => void) | null = null
83
- let remountPath: string | null = null
84
- let pendingRemount = false
83
+ /** Incremented only when route.set(..., { remount: true }); fragment key is m-route-${remountNonce}. */
84
+ let remountNonce = 0
85
85
 
86
86
  const RouterRoot: ComponentType = {
87
87
  onremove: function () {
@@ -94,21 +94,20 @@ export default function router($window: any, mountRedraw: MountRedraw) {
94
94
  // if (!hasBeenResolved) return
95
95
 
96
96
  // Pass currentPath in attrs so RouteResolver.render can use it for routePath.
97
- // Use remountPath when set (from route.set with remount: true) to force remount
98
- // for same-route navigation; otherwise attrs.key for route param changes.
97
+ // No vnode key on the route component: same resolved component updates in place when path/query
98
+ // changes; apps sync state via attrs/onupdate. Do not use attrs.key from merged query (?key=...).
99
99
  const routeAttrs = {...attrs, routePath: currentPath || attrs.routePath}
100
- const vnodeKey = remountPath ?? attrs.key
101
- const vnode = Vnode(component, vnodeKey, routeAttrs, null, null, null)
100
+ const vnode = Vnode(component, undefined, routeAttrs, null, null, null)
102
101
  if (currentResolver) {
103
102
  const result = currentResolver.render!(vnode as any)
104
- // When remount requested, wrap in keyed fragment so same-route navigation
105
- // (e.g. Stock <-> TBO) forces full remount. Key must change when path changes.
106
- // Always wrap when we have a path so structure is consistent; key drives remount.
107
- const fragmentKey = remountPath ?? currentPath ?? undefined
108
- if (remountPath && fragmentKey != null) {
109
- return hyperscript.fragment({key: fragmentKey}, result)
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
110
109
  }
111
- return result
110
+ return hyperscript.fragment({key: 'm-route-' + remountNonce}, result)
112
111
  }
113
112
  // Wrap in a fragment to preserve existing key semantics
114
113
  return [vnode]
@@ -188,7 +187,10 @@ export default function router($window: any, mountRedraw: MountRedraw) {
188
187
  const path = decodeURIComponentSafe(prefix).slice(route.prefix.length)
189
188
  const data = parsePathname(path)
190
189
 
191
- Object.assign(data.params, $window.history.state || {})
190
+ const histState = $window?.history?.state
191
+ if (histState != null && typeof histState === 'object' && !Array.isArray(histState)) {
192
+ Object.assign(data.params, histState)
193
+ }
192
194
 
193
195
  function reject(e: any) {
194
196
  console.error(e)
@@ -248,10 +250,6 @@ export default function router($window: any, mountRedraw: MountRedraw) {
248
250
  }
249
251
  attrs = data.params
250
252
  currentPath = path
251
- if (pendingRemount) {
252
- remountPath = path
253
- pendingRemount = false
254
- }
255
253
  lastUpdate = null
256
254
  routeSetResolve?.()
257
255
  routeSetResolve = null
@@ -339,10 +337,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
339
337
  }
340
338
  lastUpdate = null
341
339
 
342
- if (!options?.remount) {
343
- remountPath = null
340
+ if (options?.remount) {
341
+ remountNonce++
344
342
  }
345
- pendingRemount = options?.remount ?? false
346
343
 
347
344
  path = buildPathname(path, data || {})
348
345
  if (!ready || !$window) {
@@ -368,9 +365,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
368
365
  }
369
366
  route.get = function (): string {
370
367
  // If currentPath is not set (e.g., during SSR before route.resolve is called),
371
- // 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
372
369
  if (currentPath === undefined) {
373
- return getPathname()
370
+ return getPathname() + getSearch()
374
371
  }
375
372
  return currentPath ?? ''
376
373
  }
@@ -618,14 +615,6 @@ export default function router($window: any, mountRedraw: MountRedraw) {
618
615
  // resolver.render does: m(layout, null, componentVnode)
619
616
  const renderedVnode = resolver.render(componentVnode)
620
617
  const result = await renderToString(renderedVnode)
621
- const html = typeof result === 'string' ? result : result.html
622
- if (html) {
623
- logger.info(`rendered route component`, {
624
- pathname,
625
- route: matchedRoute,
626
- htmlSize: html.length,
627
- })
628
- }
629
618
  return result
630
619
  } catch (error) {
631
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
@@ -1,5 +1,8 @@
1
1
  import parsePathname from './parse'
2
2
 
3
+ /** Path segments, :params, and regexp escapes (shared by compileTemplate). */
4
+ const TEMPLATE_PATH_TO_REGEXP = /:([^/.-]+)(\.{3}|\.(?!\.)|-)?|[\\^$*+.()|[\]{}]/g
5
+
3
6
  interface CompiledTemplate {
4
7
  (data: {path: string; params: Record<string, any>}): boolean
5
8
  }
@@ -20,7 +23,7 @@ export default function compileTemplate(template: string): CompiledTemplate {
20
23
  // `:lang-:locale` in routes. This is all merged into one pass so I
21
24
  // don't also accidentally escape `-` and make it harder to detect it to
22
25
  // ban it from template parameters.
23
- /:([^/.-]+)(\.{3}|\.(?!\.)|-)?|[\\^$*+.()|[\]{}]/g,
26
+ TEMPLATE_PATH_TO_REGEXP,
24
27
  function (m, key, extra) {
25
28
  if (key == null) return '\\' + m
26
29
  keys.push({k: key, r: extra === '...'})
@@ -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
- export default new Map([[emptyAttrs, true]])
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 {logHydrationError, resetHydrationErrorCount} from '../util/ssr'
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
- const fragment = getDocument(parent as Element).createDocumentFragment()
238
- if (vnode.children != null) {
239
- const children = vnode.children
240
- createNodes(fragment, children, 0, children.length, hooks, null, ns, isHydrating, matchedNodes)
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
- // During hydration, if we reused an element, it already has children
330
- // Create a new matchedNodes set for this element's children to avoid duplicates
331
- const childMatchedNodes = isHydrating && element.firstChild ? new Set<Node>() : null
332
- createNodes(element, children, 0, children.length, hooks, null, ns, isHydrating, childMatchedNodes)
333
- // After creating/matching children, remove any unmatched nodes that remain
334
- // Only remove unmatched nodes if we actually matched some nodes (to avoid clearing everything)
335
- if (isHydrating && childMatchedNodes && element.firstChild && childMatchedNodes.size > 0) {
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
- if (currentRedraw != null) {
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 = ''
@@ -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 = deserializedValue
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.bright + colors.blue),
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
- // Always use the set prefix (e.g., [app] or [ssr])
90
- const prefixStr = colorize(
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, colors.cyan))
189
+ contextParts.push(colorize(context.method, methodColor))
106
190
  }
107
191
  if (context.pathname) {
108
- contextParts.push(colorize(context.pathname, colors.green))
192
+ contextParts.push(colorize(context.pathname, pathColor))
109
193
  }
110
194
  if (context.route) {
111
- contextParts.push(colorize(`route:${context.route}`, colors.blue))
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)}...`, colors.dim + colors.white))
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 (!['method', 'pathname', 'route', 'sessionId', 'module'].includes(key)) {
120
- contextParts.push(colorize(`${key}:${value}`, colors.dim + colors.white))
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
- private formatContextForBrowser(context?: LogContext): string[] {
133
- if (!context) return []
134
- const parts: string[] = []
135
- if (context.method) parts.push(`Method: ${context.method}`)
136
- if (context.pathname) parts.push(`Path: ${context.pathname}`)
137
- if (context.route) parts.push(`Route: ${context.route}`)
138
- if (context.sessionId) parts.push(`Session: ${context.sessionId.slice(0, 8)}...`)
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 (!['method', 'pathname', 'route', 'sessionId', 'module'].includes(key)) {
141
- parts.push(`${key}: ${value}`)
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: #3b82f6; font-weight: bold'
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: #4ade80; font-weight: bold',
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 contextParts = this.formatContextForBrowser(context)
163
- if (contextParts.length > 0 || error) {
164
- console.group(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level], 'color: inherit')
165
- contextParts.forEach((part) => logFn(` ${part}`))
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], 'color: inherit')
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 templateUrl = `http://localhost:${port}${templateRoute}`
53
+ const template_url = `http://localhost:${port}${templateRoute}`
51
54
  try {
52
- const response = await fetch(templateUrl)
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 routeCount = Object.keys(options.routes).length
99
- logger.debug('resolving route', {pathname, routeCount, routeExists: !!options.routes[pathname]})
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 serializedState = typeof result === 'string' ? {} : result.state
105
- const htmlLength = appHtml?.length || 0
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
- logger.debug('route resolved', {pathname, htmlLength, resultType: typeof result === 'string' ? 'string' : 'object'})
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 stateScript = `<script id="${stateScriptId}" type="application/json">${JSON.stringify(serializedState)}</script>`
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
- // eslint-disable-next-line @typescript-eslint/naming-convention
175
+ //
146
176
  'Content-Type': 'text/html; charset=utf-8',
147
- // eslint-disable-next-line @typescript-eslint/naming-convention
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 session update API handler factory
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
- * State type - reactive object with signal-based properties
871
- *
872
- * Supports:
873
- * - Regular access: `state.prop` returns unwrapped value
874
- * - Signal access: `state.$prop` returns Signal instance ($ prefix convention)
875
- * - Functions become computed signals
876
- * - Nested objects become State instances (recursively)
877
- */
878
- export type State<T extends Record<string, any>> = {
879
- [K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : T[K] extends Record<string, any> ? State<T[K]> : T[K]
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
- if (Object.prototype.hasOwnProperty.call(state, key)) {
188
- const blueprintValue = (blueprint as any)[key]
189
- const stateValue = (state as any)[key]
190
- if (!Array.isArray(blueprintValue) && blueprintValue !== null && is_object(blueprintValue)) {
191
- // (!) Convention: The contents of a state key with the name 'lookup' is
192
- // always one-one copied from the state, instead of being
193
- // blueprinted per-key. This is to accomodate key/value
194
- // lookups, without having to define each key in the
195
- // state's persistent section.
196
- if (key === 'lookup') {
197
- result[key] = copy_object(stateValue)
198
- } else {
199
- result[key] = this.blueprint(stateValue, blueprintValue)
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
- function formatComponentHierarchy(vnode: any, context?: {oldVnode?: any; newVnode?: any}): string {
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)