@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 +1 -1
- package/render/render.ts +5 -1
- package/render/vnode.ts +7 -1
- package/server.ts +4 -1
- package/state.ts +18 -6
- package/store.ts +97 -8
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
369
|
-
* sessionStorage (tab)
|
|
410
|
+
* Persist state to storage. When no options are passed, saves to localStorage (saved),
|
|
411
|
+
* sessionStorage (tab), and the cookie tier (when a cookie template is registered). Session
|
|
412
|
+
* (server-side) is off by default; pass { session: true } to persist it.
|
|
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
|
|
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 {
|