@bitstillery/mithril 3.8.0 → 3.9.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/api/router.ts CHANGED
@@ -26,6 +26,15 @@ export interface RouteResolver<Attrs = Record<string, any>, State = any> {
26
26
  export type SSRState = Record<string, any>
27
27
  export type SSRResult = string | {html: string; state: SSRState}
28
28
 
29
+ /**
30
+ * A route parameter as the URL can carry it. Path params are always strings; the querystring parser
31
+ * also turns `true`/`false` into booleans and `a[]=`/`a[b]=` keys into arrays and objects. A plain
32
+ * object in `history.state` is merged over the params too, so values put there must keep to this
33
+ * shape for the type to hold.
34
+ */
35
+ export type RouteParamValue = string | boolean | RouteParamValue[] | {[key: string]: RouteParamValue}
36
+ export type RouteParams = Record<string, RouteParamValue>
37
+
29
38
  export interface Route {
30
39
  (path: string, params?: Record<string, any>, shouldReplaceHistory?: boolean): void
31
40
  (path: string, component: ComponentType, shouldReplaceHistory?: boolean): void
@@ -33,7 +42,10 @@ export interface Route {
33
42
  get: () => string
34
43
  prefix: string
35
44
  link: (vnode: VnodeType) => string
36
- param: (key?: string) => any
45
+ param: {
46
+ (key: string): RouteParamValue | undefined
47
+ (): RouteParams
48
+ }
37
49
  params: Record<string, any>
38
50
  Link: ComponentType
39
51
  SKIP: {}
package/index.ts CHANGED
@@ -12,7 +12,15 @@ import censor from './util/censor'
12
12
  import nextTick from './util/next_tick'
13
13
  import domFor from './render/domFor'
14
14
  import {signal, computed, effect, Signal, ComputedSignal, setSignalRedrawCallback, getSignalComponents} from './signal'
15
- import {state, watch, registerState, getRegisteredStates, clearStateRegistry, copyGlobalStatesToContext} from './state'
15
+ import {
16
+ state,
17
+ watch,
18
+ registerState,
19
+ getRegisteredStates,
20
+ clearStateRegistry,
21
+ copyGlobalStatesToContext,
22
+ allowComputed,
23
+ } from './state'
16
24
 
17
25
  import type {Vnode, Children, ComponentType} from './render/vnode'
18
26
  import type {Hyperscript} from './render/hyperscript'
@@ -101,8 +109,20 @@ setSignalRedrawCallback((sig: Signal<any>) => {
101
109
  })
102
110
 
103
111
  // Export signals API
104
- export {signal, computed, effect, Signal, ComputedSignal, state, watch, registerState, getRegisteredStates, clearStateRegistry}
105
- export type {State, StateArray, StateOptions, StateSignals, Unwatch} from './state'
112
+ export {
113
+ signal,
114
+ computed,
115
+ effect,
116
+ Signal,
117
+ ComputedSignal,
118
+ state,
119
+ watch,
120
+ registerState,
121
+ getRegisteredStates,
122
+ clearStateRegistry,
123
+ allowComputed,
124
+ }
125
+ export type {DeepPartial, State, StateArray, StateOptions, StateSignals, Unwatch} from './state'
106
126
 
107
127
  // Export Store class
108
128
  export {Store} from './store'
@@ -140,7 +160,7 @@ export type {
140
160
  } from './render/vnode'
141
161
  export {MithrilComponent}
142
162
  export type {Hyperscript} from './render/hyperscript'
143
- export type {Route, RouteResolver, RedirectObject} from './api/router'
163
+ export type {Route, RouteParams, RouteParamValue, RouteResolver, RedirectObject} from './api/router'
144
164
  export type {Render, Redraw, Mount} from './api/mount-redraw'
145
165
 
146
166
  // Namespace merge: enables m.Vnode<Attrs> and m.Children when using import m from '@bitstillery/mithril'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.8.0",
3
+ "version": "3.9.1",
4
4
  "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
@@ -52,7 +52,7 @@
52
52
  "lint:ts": "bun run lint:ts-format && bun run lint:ts-syntax && bun run lint:ts-types",
53
53
  "lint:ts-format": "oxfmt",
54
54
  "lint:ts-syntax": "oxlint -c .oxlintrc.json",
55
- "lint:ts-types": "tsgo --noEmit",
55
+ "lint:ts-types": "tsc --noEmit",
56
56
  "test": "bun test",
57
57
  "bench": "NODE_ENV=production bun run bench/index.ts",
58
58
  "bench:hyperscript": "NODE_ENV=production bun run bench/index.ts hyperscript",
@@ -60,7 +60,7 @@
60
60
  "bench:signal": "NODE_ENV=production bun run bench/index.ts signal",
61
61
  "bench:state": "NODE_ENV=production bun run bench/index.ts state",
62
62
  "bench:profile": "bun --cpu-prof-md run bench/index.ts",
63
- "prepublishOnly": "bun run lint:ts",
63
+ "prepublishOnly": "oxfmt --check && bun run lint:ts-syntax && bun run lint:ts-types",
64
64
  "pack:dry-run": "npm pack --dry-run",
65
65
  "release": "./scripts/release.sh",
66
66
  "release:patch": "./scripts/release.sh --release-as patch",
@@ -78,7 +78,6 @@
78
78
  "@codemirror/theme-one-dark": "^6.1.2",
79
79
  "@codemirror/view": "^6.39.1",
80
80
  "@types/bun": "^1.3.9",
81
- "@typescript/native-preview": "^7.0.0-dev.20260120.1",
82
81
  "codemirror": "^6.0.1",
83
82
  "marked": "^15.0.4",
84
83
  "marked-gfm-heading-id": "^4.0.0",
@@ -86,6 +85,7 @@
86
85
  "oxfmt": "^0.32.0",
87
86
  "oxlint": "^1.30.0",
88
87
  "standard-version": "^9.5.0",
89
- "tippy.js": "^6.3.7"
88
+ "tippy.js": "^6.3.7",
89
+ "typescript": "^7.0.2"
90
90
  }
91
91
  }
package/render/vnode.ts CHANGED
@@ -28,19 +28,26 @@ export type VnodeDOM<Attrs = Record<string, any>, State = any> = ComponentVnode<
28
28
  */
29
29
  export type ComponentVnode<Attrs = Record<string, any>, State = any> = Omit<Vnode<Attrs, State>, 'attrs'> & {attrs: Attrs}
30
30
 
31
+ /**
32
+ * The hooks are declared as methods, not function-typed properties: under `strictFunctionTypes` a
33
+ * property's parameters are checked contravariantly, so `Component<{name: string}>` would not be a
34
+ * `Component<Record<string, any>>` and `m(Icon, attrs)` could not be passed where `Children` is expected.
35
+ * Method parameters are bivariant, which matches how Mithril really calls them — with the vnode it
36
+ * built for that component.
37
+ */
31
38
  export interface Component<Attrs = Record<string, any>, State = any> {
32
- oninit?: (vnode: ComponentVnode<Attrs, State>) => void
33
- oncreate?: (vnode: ComponentVnode<Attrs, State>) => void
34
- onbeforeupdate?: (vnode: ComponentVnode<Attrs, State>, old: ComponentVnode<Attrs, State>) => boolean | void
35
- onupdate?: (vnode: ComponentVnode<Attrs, State>) => void
36
- onbeforeremove?: (vnode: ComponentVnode<Attrs, State>) => Promise<any> | void
37
- onremove?: (vnode: ComponentVnode<Attrs, State>) => void
38
- view: (vnode: ComponentVnode<Attrs, State>) => Children | Vnode | null
39
+ oninit?(vnode: ComponentVnode<Attrs, State>): void
40
+ oncreate?(vnode: ComponentVnode<Attrs, State>): void
41
+ onbeforeupdate?(vnode: ComponentVnode<Attrs, State>, old: ComponentVnode<Attrs, State>): boolean | void
42
+ onupdate?(vnode: ComponentVnode<Attrs, State>): void
43
+ onbeforeremove?(vnode: ComponentVnode<Attrs, State>): Promise<any> | void
44
+ onremove?(vnode: ComponentVnode<Attrs, State>): void
45
+ view(vnode: ComponentVnode<Attrs, State>): Children | Vnode | null
39
46
  }
40
47
 
41
48
  export interface ComponentFactory<Attrs = Record<string, any>, State = any> {
42
49
  (...args: any[]): Component<Attrs, State>
43
- view?: (vnode: ComponentVnode<Attrs, State>) => Children | Vnode | null
50
+ view?(vnode: ComponentVnode<Attrs, State>): Children | Vnode | null
44
51
  }
45
52
 
46
53
  export type ComponentType<Attrs = Record<string, any>, State = any> =
@@ -55,7 +62,7 @@ export type ComponentType<Attrs = Record<string, any>, State = any> =
55
62
  */
56
63
  export abstract class MithrilComponent<Attrs = Record<string, any>> {
57
64
  /** Required for JSX attribute type-checking - do not use directly */
58
- private readonly __tsx_attrs!: (unknown extends Attrs ? Record<string, any> : Attrs) & {key?: string | number}
65
+ private readonly __tsx_attrs!: (unknown extends Attrs ? Record<string, any> : Attrs) & {key?: string | number | null}
59
66
 
60
67
  oninit?(vnode: ComponentVnode<Attrs>): void
61
68
  oncreate?(vnode: ComponentVnode<Attrs>): void
package/ssrContext.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * request's context. No globals, safe under concurrent requests.
6
6
  * In the browser, getSSRContext() returns undefined and runWithContext just runs fn.
7
7
  */
8
+ import type {Store} from './store'
9
+
8
10
  type StorageLike = {
9
11
  getStore(): SSRAccessContext | undefined
10
12
  run<T>(context: SSRAccessContext, fn: () => T): T
@@ -28,7 +30,7 @@ try {
28
30
  * that runs inside the same runWithContext() call.
29
31
  */
30
32
  export interface SSRAccessContext {
31
- store?: any
33
+ store?: Store
32
34
  /** Per-request state registry for serialization; fresh Map per request. */
33
35
  stateRegistry: Map<string, {state: any; initial: any}>
34
36
  sessionId?: string
package/state.ts CHANGED
@@ -232,15 +232,17 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
232
232
 
233
233
  // Handle arrays
234
234
  if (Array.isArray(obj)) {
235
- // Arrays don't get their own signalMap - they use the parent's
236
- // Nested objects AND arrays should be recursively wrapped
237
- const signals = obj.map((item: any) => {
235
+ // Init, push/unshift, splice and index assignment all wrap through here, so an element typed as
236
+ // `State<E>` is one: objects and arrays become their own state proxy (the proxy is the element, not a
237
+ // signal around it), everything else a signal.
238
+ const toElement = (item: any) => {
238
239
  if (typeof item === 'object' && item !== null) {
239
- // Recursively wrap nested objects AND arrays in Proxies
240
240
  return initializeSignals(item, undefined, context)
241
241
  }
242
242
  return toSignal(item)
243
- })
243
+ }
244
+ // Arrays don't get their own signalMap - they use the parent's
245
+ const signals = obj.map(toElement)
244
246
 
245
247
  // List of mutating array methods that should trigger the parent signal
246
248
  const mutatingMethods = new Set([
@@ -354,14 +356,7 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
354
356
  const deleteCount = args[1] ?? signals.length - start
355
357
  const newItems = args.slice(2)
356
358
 
357
- // Convert new items - nested arrays/objects become Proxies, primitives become Signals
358
- const newSignals = newItems.map((item: any) => {
359
- if (typeof item === 'object' && item !== null) {
360
- // Wrap objects/arrays in Proxies (NOT in Signals - the Proxy IS the value)
361
- return initializeSignals(item, undefined, context)
362
- }
363
- return toSignal(item)
364
- })
359
+ const newSignals = newItems.map(toElement)
365
360
 
366
361
  // Update the signals array (target is signals array)
367
362
  const removed = signals.splice(start, deleteCount, ...newSignals)
@@ -399,14 +394,7 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
399
394
  let result
400
395
  if (propStr === 'push' || propStr === 'unshift') {
401
396
  const newItems = args
402
- // Convert new items - nested arrays/objects become Proxies, primitives become Signals
403
- const newSignals = newItems.map((item: any) => {
404
- if (typeof item === 'object' && item !== null) {
405
- // Wrap objects/arrays in Proxies (NOT in Signals - the Proxy IS the value)
406
- return initializeSignals(item, undefined, context)
407
- }
408
- return toSignal(item)
409
- })
397
+ const newSignals = newItems.map(toElement)
410
398
  if (propStr === 'push') {
411
399
  result = signals.push(...newSignals)
412
400
  } else {
@@ -449,9 +437,11 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
449
437
  const fillValue = args[0]
450
438
  const start = args[1] ?? 0
451
439
  const end = args[2] ?? signals.length
452
- const fillSignal = toSignal(fillValue)
440
+ // A wrap per slot, so no two slots share a signal. An object fill value still reads
441
+ // back as one shared element in every slot, as native fill shares the reference:
442
+ // `toElement` returns the cached proxy for the same object.
453
443
  for (let i = start; i < end; i++) {
454
- signals[i] = fillSignal
444
+ signals[i] = toElement(fillValue)
455
445
  }
456
446
  result = signals.length
457
447
  } else {
@@ -493,12 +483,14 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
493
483
  set(target, prop, value) {
494
484
  if (typeof prop === 'string' && !isNaN(Number(prop))) {
495
485
  const index = Number(prop)
496
- if (index >= 0 && index < signals.length) {
486
+ // Assigning past the end (`arr[arr.length] = x`) appends, so it wraps and notifies like push.
487
+ if ((index >= 0 && index < signals.length) || (Number.isInteger(index) && index >= signals.length)) {
497
488
  const sig = signals[index]
498
- if (isSignal(sig)) {
489
+ // A primitive over a primitive keeps the element's signal, so `arr.$i` subscribers see it.
490
+ if (isSignal(sig) && (typeof value !== 'object' || value === null)) {
499
491
  sig.value = value
500
492
  } else {
501
- signals[index] = toSignal(value)
493
+ signals[index] = toElement(value)
502
494
  }
503
495
  // Trigger parent signal on element assignment (look up when called)
504
496
  const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
@@ -520,9 +512,13 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
520
512
  }
521
513
  }
522
514
  return true
523
- } else if (prop === 'length') {
524
- signals.length = Number(value)
525
- // Trigger parent signal on length change (look up when called)
515
+ }
516
+ }
517
+ if (prop === 'length') {
518
+ const previousLength = signals.length
519
+ const result = Reflect.set(target, prop, value)
520
+ // Resizing adds or drops elements without going through a mutator, so it notifies like splice.
521
+ if (signals.length !== previousLength) {
526
522
  const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
527
523
  if (parentSignal) {
528
524
  // Notify subscribers directly since the array reference hasn't changed
@@ -541,8 +537,8 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
541
537
  ;(signal as any).__redrawCallback(parentSignal)
542
538
  }
543
539
  }
544
- return true
545
540
  }
541
+ return result
546
542
  }
547
543
  return Reflect.set(target, prop, value)
548
544
  },
@@ -871,11 +867,13 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
871
867
  * Mapped type that adds $prop for each key, returning the Signal for that property.
872
868
  * - Primitives: $prop => Signal<T[K]>
873
869
  * - Nested objects: $prop => Signal<State<T[K]>>
874
- * - Functions: $prop => ComputedSignal (computed from getter)
870
+ * - Functions: $prop => ComputedSignal of the getter's return type
875
871
  */
876
872
  export type StateSignals<T extends Record<string, any>> = {
877
- [K in keyof T as K extends string ? `$${K}` : never]: T[K] extends (...args: any[]) => any
878
- ? ComputedSignal<any>
873
+ // Only for declared keys: a record's index signature would otherwise gain a `$${string}` twin, and every
874
+ // lookup by a `string` key would read as `Value | Signal<Value>`.
875
+ [K in keyof T as K extends string ? (string extends K ? never : `$${K}`) : never]: T[K] extends (...args: any[]) => infer R
876
+ ? ComputedSignal<R>
879
877
  : T[K] extends object
880
878
  ? Signal<State<T[K]>>
881
879
  : Signal<T[K]>
@@ -895,6 +893,27 @@ export type State<T extends Record<string, any>> = T extends (infer Elem)[]
895
893
  [K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : T[K] extends Record<string, any> ? State<T[K]> : T[K]
896
894
  } & StateSignals<T>
897
895
 
896
+ /**
897
+ * A partial of a state shape at every depth, for the persistence tiers of a Store: each tier fills in
898
+ * part of a nested object (`saved` some keys of `exact`, `temporary` the rest). Arrays and computed
899
+ * getters are replaced wholesale, never merged element-wise.
900
+ */
901
+ export type DeepPartial<T> = T extends (...args: any[]) => any
902
+ ? T
903
+ : T extends readonly unknown[]
904
+ ? T
905
+ : T extends object
906
+ ? {[K in keyof T]?: DeepPartial<T[K]>}
907
+ : T
908
+
909
+ /**
910
+ * Opens the deferred-computed gate of a state built with `deferComputed` (ADR-0013) and marks its
911
+ * computeds dirty. The gate is a proxy trap rather than a key of the state, so it isn't on `State<T>`.
912
+ */
913
+ export function allowComputed(stateInstance: State<any>): void {
914
+ ;(stateInstance as unknown as {allowComputed?: () => void}).allowComputed?.()
915
+ }
916
+
898
917
  /** Function returned by watch() to remove the watcher */
899
918
  export type Unwatch = () => void
900
919
 
package/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {state, State, updateStateRegistry} from './state'
1
+ import {allowComputed, state, State, updateStateRegistry, type DeepPartial} from './state'
2
2
  import {serializeStore, deserializeStore} from './render/ssrState'
3
3
 
4
4
  // Helper function to restore computed properties (same as in ssrState.ts)
@@ -143,11 +143,11 @@ let storeInstanceCounter = 0
143
143
  export class Store<T extends Record<string, any> = Record<string, any>> {
144
144
  private stateInstance: State<T>
145
145
  private templates = {
146
- saved: {} as Partial<T>,
147
- temporary: {} as Partial<T>,
148
- tab: {} as Partial<T>,
149
- session: {} as Partial<T>,
150
- cookie: {} as Partial<T>,
146
+ saved: {} as DeepPartial<T>,
147
+ temporary: {} as DeepPartial<T>,
148
+ tab: {} as DeepPartial<T>,
149
+ session: {} as DeepPartial<T>,
150
+ cookie: {} as DeepPartial<T>,
151
151
  }
152
152
  private lookup_verify_interval: number | null = null
153
153
  private lookup_ttl: number
@@ -193,15 +193,15 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
193
193
  * (e.g. after $s, context, or route are ready) so computeds that depend on them can run.
194
194
  */
195
195
  ready(): void {
196
- ;(this.stateInstance as any).allowComputed?.()
196
+ allowComputed(this.stateInstance)
197
197
  }
198
198
 
199
199
  /**
200
200
  * Merge deep on object `state`, but only the key/values in `blueprint`.
201
201
  */
202
- blueprint(state: T, blueprint: Partial<T>): Partial<T> {
202
+ blueprint(state: T, blueprint: DeepPartial<T>): DeepPartial<T> {
203
203
  if (state == null || typeof state !== 'object') {
204
- return {} as Partial<T>
204
+ return {} as DeepPartial<T>
205
205
  }
206
206
  const result: any = {}
207
207
  for (const key of Object.keys(blueprint)) {
@@ -227,7 +227,7 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
227
227
  result[key] = stateValue
228
228
  }
229
229
  }
230
- return result as Partial<T>
230
+ return result as DeepPartial<T>
231
231
  }
232
232
 
233
233
  clean_lookup() {
@@ -297,11 +297,11 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
297
297
  }
298
298
 
299
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>,
300
+ saved: DeepPartial<T>,
301
+ temporary: DeepPartial<T>,
302
+ tab: DeepPartial<T> = {} as DeepPartial<T>,
303
+ session: DeepPartial<T> = {} as DeepPartial<T>,
304
+ cookie: DeepPartial<T> = {} as DeepPartial<T>,
305
305
  ) {
306
306
  const restored_state = {
307
307
  tab: this.get_tab_storage(this.tabStorageKey),