@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 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
- // Always wrap in a keyed fragment (stable shape). Key only changes when remountNonce bumps
104
- // (route.set with remount: true), not on ordinary URL updates.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.4.2",
3
+ "version": "3.7.0",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
@@ -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
@@ -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
- constructor(options: {lookup_ttl?: number; storageKey?: string; tabStorageKey?: string} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
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
- 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
- }
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(saved: Partial<T>, temporary: Partial<T>, tab: Partial<T> = {} as Partial<T>, session: Partial<T> = {} as Partial<T>) {
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
- const mergedInitial = merge_deep(mergedInitialSaved, mergedInitialTemporary, mergedInitialTab, mergedInitialSession)
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) and
367
- * sessionStorage (tab). Session (server-side) is off by default; pass { session: true } to persist it.
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 sessionStorage when no options; session is opt-in
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
- 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)