@bitstillery/mithril 3.6.0 → 3.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
package/render/render.ts CHANGED
@@ -691,7 +691,11 @@ export default function renderFactory() {
691
691
  const originalNextSibling = nextSibling
692
692
  let pos = 2147483647,
693
693
  matched = 0
694
- const oldIndices = Array.from({length: end - start + 1}, () => -1)
694
+ // See the note in vnode.ts's normalizeChildren: `Array.from({length: n}, () => -1)`
695
+ // is ~14x slower than this on V8 (~146ms vs ~10ms for 200k calls at 24 entries),
696
+ // since it adds a per-element mapper call on top of the slower allocation path.
697
+ // oxlint-disable-next-line no-new-array
698
+ const oldIndices = new Array(end - start + 1).fill(-1)
695
699
  const map: Record<string, number> = Object.create(null)
696
700
  for (let i = start; i <= end; i++) {
697
701
  if (vnodes[i] != null) map[vnodes[i]!.key!] = i
package/render/vnode.ts CHANGED
@@ -102,7 +102,13 @@ const normalize = function (node: any): Vnode | null {
102
102
  const normalizeChildren = function (input: any[]): (Vnode | null)[] {
103
103
  // Preallocate the array length (initially holey) and fill every index immediately in order.
104
104
  // Benchmarking shows better performance on V8.
105
- const children = Array.from({length: input.length}) as (Vnode | null)[]
105
+ //
106
+ // Do NOT let a linter rewrite this to `Array.from({length: n})`: that is not holey, it walks the
107
+ // array-like/iterator path, and on V8 it is ~15x slower here (measured on node 22: ~98ms vs ~6.4ms
108
+ // for 200k calls at 12 children). This is the hottest function in a portal render profile, so the
109
+ // difference is not academic — it was silently lost once already to an oxlint autofix.
110
+ // oxlint-disable-next-line no-new-array
111
+ const children = new Array(input.length) as (Vnode | null)[]
106
112
  // Count the number of keyed normalized vnodes for consistency check.
107
113
  // Note: this is a perf-sensitive check.
108
114
  // Fun fact: merging the loop like this is somehow faster than splitting
package/server.ts CHANGED
@@ -14,7 +14,10 @@ import censor from './util/censor'
14
14
  import type {MithrilStatic, Hyperscript} from './index'
15
15
  import type {Redraw} from './api/mount-redraw'
16
16
 
17
- logger.debug(`mithril ssr v${version}`)
17
+ // `process.env.MSI_BUILD_COMMIT` is inlined by the SSR bundle's `define` (see frontend/cli/tasks.ts).
18
+ // It is undefined when running unbundled (e.g. mithril's own tests), hence the fallback.
19
+ const buildCommit = process.env.MSI_BUILD_COMMIT || 'dev'
20
+ logger.debug(`mithril ssr v${version} (commit ${buildCommit})`)
18
21
 
19
22
  // Create server-side renderer
20
23
  const {renderToString, renderToStringSync} = renderToStringFactory()
package/state.ts CHANGED
@@ -619,12 +619,22 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
619
619
  }
620
620
 
621
621
  const ensurePropertySignal = (target: any, prop: string | symbol, key: string) => {
622
- if (!nestedSignalMap.has(key)) {
623
- const originalValue = Reflect.get(target, prop)
624
- if (originalValue === undefined) return undefined
625
- nestedSignalMap.set(key, createPropertySignal(originalValue))
622
+ // One Map lookup, not two. This runs on EVERY property read of every state object — the
623
+ // hottest path in the framework — and after the first access the signal always exists, so the
624
+ // old `has()` + `get()` pair doubled the cost of the common case.
625
+ //
626
+ // `get()` returning undefined is an exact substitute for `!has()`: createPropertySignal never
627
+ // returns undefined (it always yields a Signal, falling back to `signal(undefined)`), and the
628
+ // early return below stores nothing.
629
+ const existing = nestedSignalMap.get(key)
630
+ if (existing !== undefined) {
631
+ return existing
626
632
  }
627
- return nestedSignalMap.get(key)
633
+ const originalValue = Reflect.get(target, prop)
634
+ if (originalValue === undefined) return undefined
635
+ const created = createPropertySignal(originalValue)
636
+ nestedSignalMap.set(key, created)
637
+ return created
628
638
  }
629
639
  wrapped = new Proxy(obj, {
630
640
  get(target, prop) {
@@ -647,7 +657,9 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
647
657
  }
648
658
  }
649
659
 
650
- const propStr = String(prop)
660
+ // Property keys are already strings in virtually every access; `String()` on a symbol is
661
+ // the rare path, so don't make the common one pay for the conversion.
662
+ const propStr = typeof prop === 'string' ? prop : String(prop)
651
663
 
652
664
  // Check for $ prefix convention (deepsignal-style: returns raw signal)
653
665
  if (propStr.startsWith('$') && propStr.length > 1) {
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})
@@ -275,7 +296,13 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
275
296
  }
276
297
  }
277
298
 
278
- 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
+ ) {
279
306
  const restored_state = {
280
307
  tab: this.get_tab_storage(this.tabStorageKey),
281
308
  store: this.get(this.storageKey),
@@ -286,6 +313,7 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
286
313
  temporary,
287
314
  tab,
288
315
  session,
316
+ cookie,
289
317
  }
290
318
 
291
319
  try {
@@ -321,6 +349,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
321
349
  // Session state comes from server (SSR), not localStorage
322
350
  const final_state = merge_deep(temp_state, copy_object(session))
323
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
+
324
358
  // Merge templates (including computed properties) into "merged initial state"
325
359
  // This will be stored in registry so computed properties can be automatically restored
326
360
  // Use copy_object_preserve_functions to deep copy while preserving functions
@@ -331,7 +365,15 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
331
365
  const mergedInitialTab = tab && Object.keys(tab).length > 0 ? {tab: copy_object_preserve_functions(tab)} : {}
332
366
  // Session template is merged directly (no nesting needed, structure matches final_state)
333
367
  const mergedInitialSession = copy_object_preserve_functions(session)
334
- 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
+ )
335
377
 
336
378
  // Update registry entry to store merged templates as "initial" state
337
379
  // This allows deserializeAllStates() to automatically restore computed properties
@@ -365,19 +407,21 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
365
407
  }
366
408
 
367
409
  /**
368
- * Persist state to storage. When no options are passed, saves to localStorage (saved) and
369
- * 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.
370
413
  */
371
- 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> {
372
415
  // Skip saving during SSR (server-side rendering in Bun)
373
416
  // On the server, there's no localStorage/sessionStorage and no need to persist state
374
417
  if (globalThis.__SSR_MODE__) {
375
418
  return
376
419
  }
377
420
 
378
- // 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
379
422
  const writeLocalStorage = options?.saved ?? options === undefined
380
423
  const writeSessionStorage = options?.tab ?? options === undefined
424
+ const writeCookie = options?.cookie ?? options === undefined
381
425
  const writeSessionApi = options?.session === true
382
426
 
383
427
  const statePlain = serializeStore(this.stateInstance)
@@ -393,6 +437,12 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
393
437
  this.persist_lookup_to_local_storage(statePlain)
394
438
  }
395
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
+
396
446
  // Write to sessionStorage (tab-scoped, cleared when tab closes)
397
447
  if (writeSessionStorage && this.templates.tab) {
398
448
  const tabState = (this.stateInstance as any).tab
@@ -476,6 +526,45 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
476
526
  }
477
527
  }
478
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
+ // Conditional so plain-http dev hosts can still write the cookie at all.
561
+ const secure = location.protocol === 'https:' ? '; Secure' : ''
562
+ document.cookie = `${key}=${value}; Path=/; SameSite=Lax${secure}; Max-Age=${this.cookieMaxAge}`
563
+ } catch (err) {
564
+ console.error('Cannot write cookie; continue without.', err)
565
+ }
566
+ }
567
+
479
568
  set_tab(key: string, item: object): void {
480
569
  if (typeof window === 'undefined') return
481
570
  try {