@bitstillery/mithril 3.1.2 → 3.3.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/README.md +25 -14
- package/api/router.ts +63 -26
- package/package.json +6 -5
- package/signal.ts +31 -23
- package/state.ts +14 -1
package/README.md
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mithril Bitstillery
|
|
2
2
|
|
|
3
|
-
Mithril
|
|
3
|
+
Mithril Bitstillery extends Mithril with integrated state management, SSR hydration, watchers, and a signal/proxy store. Drop-in compatible with Mithril v2.x.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
bun add @bitstillery/mithril
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Strengths
|
|
10
10
|
|
|
11
|
-
Mithril
|
|
11
|
+
Mithril Bitstillery focuses on **state management**, **SSR**, **watchers**, and **developer experience** around its signal/proxy store:
|
|
12
12
|
|
|
13
|
-
| Feature |
|
|
14
|
-
| ---------------- |
|
|
15
|
-
|
|
|
16
|
-
| State Management |
|
|
17
|
-
| SSR |
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
21
|
-
Signals are opt-in. Existing Mithril code works unchanged.
|
|
13
|
+
| Feature | Description |
|
|
14
|
+
| ---------------- | ------------------------------------------------------------------- |
|
|
15
|
+
| Proxy State | Reactive objects with nested objects, arrays, computeds |
|
|
16
|
+
| State Management | `state()` + `Store` for persistence (localStorage, session) |
|
|
17
|
+
| SSR | Full server-side rendering with state serialization + hydration |
|
|
18
|
+
| Watchers | `watch()` for observing signal changes; `effect()` for side effects |
|
|
19
|
+
| DX | Automatic dependency tracking, no manual redraw for signals |
|
|
22
20
|
|
|
23
21
|
**Docs**: [mithril.garage44.org](https://mithril.garage44.org)
|
|
24
22
|
|
|
@@ -38,7 +36,7 @@ count(5) // Logs: 5 × 2 = 10
|
|
|
38
36
|
|
|
39
37
|
## Proxy State
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
`state()` creates reactive objects. Components track which properties they read and only re-render when those change.
|
|
42
40
|
|
|
43
41
|
```tsx
|
|
44
42
|
import m, {state, MithrilComponent} from '@bitstillery/mithril'
|
|
@@ -61,6 +59,19 @@ m.mount(document.body, Counter)
|
|
|
61
59
|
|
|
62
60
|
The second argument to `state()` is a name used for SSR serialization.
|
|
63
61
|
|
|
62
|
+
## Watchers
|
|
63
|
+
|
|
64
|
+
`watch()` observes signal changes:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import {state, watch} from '@bitstillery/mithril'
|
|
68
|
+
|
|
69
|
+
const $s = state({count: 0}, 'app')
|
|
70
|
+
const unwatch = watch($s.$count, (newVal, oldVal) => console.log(`${oldVal} → ${newVal}`))
|
|
71
|
+
$s.count++ // triggers callback
|
|
72
|
+
unwatch() // stop observing
|
|
73
|
+
```
|
|
74
|
+
|
|
64
75
|
## SSR Hydration
|
|
65
76
|
|
|
66
77
|
```typescript
|
package/api/router.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type SSRResult = string | {html: string; state: SSRState}
|
|
|
29
29
|
export interface Route {
|
|
30
30
|
(path: string, params?: Record<string, any>, shouldReplaceHistory?: boolean): void
|
|
31
31
|
(path: string, component: ComponentType, shouldReplaceHistory?: boolean): void
|
|
32
|
-
set: (path: string, params?: Record<string, any>, data?: any) => void
|
|
32
|
+
set: (path: string, params?: Record<string, any>, data?: any) => Promise<void>
|
|
33
33
|
get: () => string
|
|
34
34
|
prefix: string
|
|
35
35
|
link: (vnode: VnodeType) => string
|
|
@@ -53,6 +53,8 @@ interface MountRedraw {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
interface RouteOptions {
|
|
56
|
+
/** When true, forces the route component to remount (e.g. for same-route navigation with different query params) */
|
|
57
|
+
remount?: boolean
|
|
56
58
|
replace?: boolean
|
|
57
59
|
state?: any
|
|
58
60
|
title?: string | null
|
|
@@ -77,6 +79,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
77
79
|
let attrs: Record<string, any> = {}
|
|
78
80
|
let currentPath: string | undefined
|
|
79
81
|
let lastUpdate: ((comp: any) => void) | null = null
|
|
82
|
+
let routeSetResolve: (() => void) | null = null
|
|
83
|
+
let remountPath: string | null = null
|
|
84
|
+
let pendingRemount = false
|
|
80
85
|
|
|
81
86
|
const RouterRoot: ComponentType = {
|
|
82
87
|
onremove: function () {
|
|
@@ -89,11 +94,22 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
89
94
|
// if (!hasBeenResolved) return
|
|
90
95
|
|
|
91
96
|
// Pass currentPath in attrs so RouteResolver.render can use it for routePath.
|
|
92
|
-
// Use
|
|
93
|
-
// route
|
|
97
|
+
// Use remountPath when set (from route.set with remount: true) to force remount
|
|
98
|
+
// for same-route navigation; otherwise attrs.key for route param changes.
|
|
94
99
|
const routeAttrs = {...attrs, routePath: currentPath || attrs.routePath}
|
|
95
|
-
const
|
|
96
|
-
|
|
100
|
+
const vnodeKey = remountPath ?? attrs.key
|
|
101
|
+
const vnode = Vnode(component, vnodeKey, routeAttrs, null, null, null)
|
|
102
|
+
if (currentResolver) {
|
|
103
|
+
const result = currentResolver.render!(vnode as any)
|
|
104
|
+
// When remount requested, wrap in keyed fragment so same-route navigation
|
|
105
|
+
// (e.g. Stock <-> TBO) forces full remount. Key must change when path changes.
|
|
106
|
+
// Always wrap when we have a path so structure is consistent; key drives remount.
|
|
107
|
+
const fragmentKey = remountPath ?? currentPath ?? undefined
|
|
108
|
+
if (remountPath && fragmentKey != null) {
|
|
109
|
+
return hyperscript.fragment({key: fragmentKey}, result)
|
|
110
|
+
}
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
97
113
|
// Wrap in a fragment to preserve existing key semantics
|
|
98
114
|
return [vnode]
|
|
99
115
|
},
|
|
@@ -176,7 +192,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
176
192
|
|
|
177
193
|
function reject(e: any) {
|
|
178
194
|
console.error(e)
|
|
179
|
-
|
|
195
|
+
const resolve = routeSetResolve
|
|
196
|
+
routeSetResolve = null
|
|
197
|
+
route.set(fallbackRoute!, null, {replace: true}).then(() => resolve?.())
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
loop(0)
|
|
@@ -206,8 +224,10 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
206
224
|
if (isRedirect(comp)) {
|
|
207
225
|
// Extract redirect target path
|
|
208
226
|
const redirectPath = comp[REDIRECT]
|
|
209
|
-
|
|
210
|
-
|
|
227
|
+
const resolve = routeSetResolve
|
|
228
|
+
routeSetResolve = null
|
|
229
|
+
// Trigger navigation to redirect target; resolve original Promise when redirect completes
|
|
230
|
+
route.set(redirectPath, null).then(() => resolve?.())
|
|
211
231
|
// Skip rendering current route - new route resolution will handle redirect target
|
|
212
232
|
return
|
|
213
233
|
}
|
|
@@ -228,7 +248,13 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
228
248
|
}
|
|
229
249
|
attrs = data.params
|
|
230
250
|
currentPath = path
|
|
251
|
+
if (pendingRemount) {
|
|
252
|
+
remountPath = path
|
|
253
|
+
pendingRemount = false
|
|
254
|
+
}
|
|
231
255
|
lastUpdate = null
|
|
256
|
+
routeSetResolve?.()
|
|
257
|
+
routeSetResolve = null
|
|
232
258
|
if (hasBeenResolved) mountRedraw.redraw()
|
|
233
259
|
else {
|
|
234
260
|
hasBeenResolved = true
|
|
@@ -255,7 +281,9 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
255
281
|
if (path === fallbackRoute) {
|
|
256
282
|
throw new Error('Could not resolve default route ' + fallbackRoute + '.')
|
|
257
283
|
}
|
|
258
|
-
|
|
284
|
+
const resolve = routeSetResolve
|
|
285
|
+
routeSetResolve = null
|
|
286
|
+
route.set(fallbackRoute!, null, {replace: true}).then(() => resolve?.())
|
|
259
287
|
}
|
|
260
288
|
}
|
|
261
289
|
|
|
@@ -304,32 +332,39 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
304
332
|
// The RouterRoot component is mounted when the route is first resolved.
|
|
305
333
|
resolveRoute()
|
|
306
334
|
}
|
|
307
|
-
route.set = function (path: string, data: Record<string, any> | null, options?: RouteOptions) {
|
|
335
|
+
route.set = function (path: string, data: Record<string, any> | null, options?: RouteOptions): Promise<void> {
|
|
308
336
|
if (lastUpdate != null) {
|
|
309
337
|
options = options || {}
|
|
310
338
|
options.replace = true
|
|
311
339
|
}
|
|
312
340
|
lastUpdate = null
|
|
313
341
|
|
|
342
|
+
if (!options?.remount) {
|
|
343
|
+
remountPath = null
|
|
344
|
+
}
|
|
345
|
+
pendingRemount = options?.remount ?? false
|
|
346
|
+
|
|
314
347
|
path = buildPathname(path, data || {})
|
|
315
|
-
if (ready) {
|
|
316
|
-
// Router
|
|
317
|
-
const state = options ? options.state : null
|
|
318
|
-
const title = options ? options.title : null
|
|
319
|
-
if ($window?.history) {
|
|
320
|
-
if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
|
|
321
|
-
else $window.history.pushState(state, title, route.prefix + path)
|
|
322
|
-
}
|
|
323
|
-
// Update URL before fireAsync so resolveRoute reads the new path
|
|
324
|
-
fireAsync()
|
|
325
|
-
// In SSR context (no $window), navigation is a no-op since we're just rendering HTML
|
|
326
|
-
} else {
|
|
327
|
-
// Router not yet initialized - use location.href for initial navigation
|
|
348
|
+
if (!ready || !$window) {
|
|
349
|
+
// Router not yet initialized or SSR - navigation is a no-op
|
|
328
350
|
if ($window?.location) {
|
|
329
351
|
$window.location.href = route.prefix + path
|
|
330
352
|
}
|
|
331
|
-
|
|
353
|
+
return Promise.resolve()
|
|
332
354
|
}
|
|
355
|
+
// Router is initialized - use history API for navigation
|
|
356
|
+
const state = options ? options.state : null
|
|
357
|
+
const title = options ? options.title : null
|
|
358
|
+
if ($window.history) {
|
|
359
|
+
if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
|
|
360
|
+
else $window.history.pushState(state, title, route.prefix + path)
|
|
361
|
+
}
|
|
362
|
+
const promise = new Promise<void>((resolve) => {
|
|
363
|
+
routeSetResolve = resolve
|
|
364
|
+
})
|
|
365
|
+
// Update URL before fireAsync so resolveRoute reads the new path
|
|
366
|
+
fireAsync()
|
|
367
|
+
return promise
|
|
333
368
|
}
|
|
334
369
|
route.get = function (): string {
|
|
335
370
|
// If currentPath is not set (e.g., during SSR before route.resolve is called),
|
|
@@ -352,11 +387,12 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
352
387
|
// let them be specified in the selector as well.
|
|
353
388
|
const child = hyperscript(
|
|
354
389
|
vnode.attrs?.selector || 'a',
|
|
355
|
-
censor(vnode.attrs || {}, ['options', 'params', 'selector', 'onclick']),
|
|
390
|
+
censor(vnode.attrs || {}, ['options', 'params', 'selector', 'onclick', 'onafternavigate']),
|
|
356
391
|
vnode.children,
|
|
357
392
|
)
|
|
358
393
|
let options: RouteOptions | undefined
|
|
359
394
|
let onclick: any
|
|
395
|
+
let onafternavigate: (() => void) | undefined
|
|
360
396
|
let href: string
|
|
361
397
|
|
|
362
398
|
// Let's provide a *right* way to disable a route link, rather than
|
|
@@ -373,6 +409,7 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
373
409
|
} else {
|
|
374
410
|
options = vnode.attrs?.options
|
|
375
411
|
onclick = vnode.attrs?.onclick
|
|
412
|
+
onafternavigate = vnode.attrs?.onafternavigate
|
|
376
413
|
// Easier to build it now to keep it isomorphic.
|
|
377
414
|
href = buildPathname(child.attrs!.href || '', vnode.attrs?.params || {})
|
|
378
415
|
// Make Link isomorphic - use empty prefix on server for pathname routing
|
|
@@ -421,7 +458,7 @@ export default function router($window: any, mountRedraw: MountRedraw) {
|
|
|
421
458
|
e.originalEvent.preventDefault()
|
|
422
459
|
}
|
|
423
460
|
;(e as any).redraw = false
|
|
424
|
-
route.set(href, null, options)
|
|
461
|
+
route.set(href, null, options).then(onafternavigate ?? (() => {}))
|
|
425
462
|
}
|
|
426
463
|
}
|
|
427
464
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitstillery/mithril",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Mithril + Signals, Store and SSR",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bitstillery",
|
|
@@ -56,10 +56,11 @@
|
|
|
56
56
|
"test": "bun test",
|
|
57
57
|
"prepublishOnly": "bun run lint:ts",
|
|
58
58
|
"pack:dry-run": "npm pack --dry-run",
|
|
59
|
-
"
|
|
60
|
-
"release
|
|
61
|
-
"release:
|
|
62
|
-
"release:
|
|
59
|
+
"publish": "./scripts/release.sh",
|
|
60
|
+
"release": "./scripts/release.sh",
|
|
61
|
+
"release:patch": "./scripts/release.sh --release-as patch",
|
|
62
|
+
"release:minor": "./scripts/release.sh --release-as minor",
|
|
63
|
+
"release:major": "./scripts/release.sh --release-as major"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"htm": "^3.1.1"
|
package/signal.ts
CHANGED
|
@@ -100,32 +100,40 @@ export class Signal<T> {
|
|
|
100
100
|
set value(newValue: T) {
|
|
101
101
|
if (this._value !== newValue) {
|
|
102
102
|
this._value = newValue
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
this.trigger()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Notify subscribers and trigger redraws without changing the value.
|
|
109
|
+
* Use when the value is an object/array that was mutated in place (e.g. keys added/removed).
|
|
110
|
+
*/
|
|
111
|
+
trigger(): void {
|
|
112
|
+
// Ensure _subscribers is initialized (defensive check)
|
|
113
|
+
if (!this._subscribers) {
|
|
114
|
+
this._subscribers = new Set()
|
|
115
|
+
}
|
|
116
|
+
// Notify all subscribers
|
|
117
|
+
const context = getSSRContext()
|
|
118
|
+
this._subscribers.forEach((fn) => {
|
|
119
|
+
try {
|
|
120
|
+
// Always run watchers - wrap in SSR context if available
|
|
121
|
+
if (context) {
|
|
122
|
+
// Run watcher inside SSR context, similar to events
|
|
123
|
+
runWithContext(context, () => {
|
|
118
124
|
fn()
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
125
|
+
})
|
|
126
|
+
} else {
|
|
127
|
+
fn()
|
|
122
128
|
}
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
// This is set up in index.ts after m.redraw is created
|
|
126
|
-
if ((signal as any).__redrawCallback) {
|
|
127
|
-
;(signal as any).__redrawCallback(this)
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error('Error in signal subscriber:', e)
|
|
128
131
|
}
|
|
132
|
+
})
|
|
133
|
+
// Trigger component redraws for affected components
|
|
134
|
+
// This is set up in index.ts after m.redraw is created
|
|
135
|
+
if ((signal as any).__redrawCallback) {
|
|
136
|
+
;(signal as any).__redrawCallback(this)
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
|
package/state.ts
CHANGED
|
@@ -211,6 +211,9 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
|
|
|
211
211
|
;(value as any)._parentSignal = sig as Signal<any>
|
|
212
212
|
} else if (Array.isArray(value)) {
|
|
213
213
|
arrayParentSignalMap.set(value, sig as Signal<any>)
|
|
214
|
+
} else if ((value as any).__isState === true) {
|
|
215
|
+
// Nested object proxies: notify parent when keys are added/removed
|
|
216
|
+
arrayParentSignalMap.set(value, sig as Signal<any>)
|
|
214
217
|
}
|
|
215
218
|
}
|
|
216
219
|
|
|
@@ -738,8 +741,13 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
|
|
|
738
741
|
nestedSignalMap.set(key, createPropertySignal(value))
|
|
739
742
|
}
|
|
740
743
|
} else {
|
|
741
|
-
// Create new signal
|
|
744
|
+
// Create new signal (new key added to object)
|
|
742
745
|
nestedSignalMap.set(key, createPropertySignal(value))
|
|
746
|
+
// Notify parent so subscribers see the key addition
|
|
747
|
+
const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
|
|
748
|
+
if (parentSignal && typeof (parentSignal as any).trigger === 'function') {
|
|
749
|
+
;(parentSignal as Signal<any>).trigger()
|
|
750
|
+
}
|
|
743
751
|
}
|
|
744
752
|
|
|
745
753
|
return true
|
|
@@ -794,6 +802,11 @@ export function state<T extends Record<string, any>>(initial: T, name?: string,
|
|
|
794
802
|
}
|
|
795
803
|
// Remove from the signal map
|
|
796
804
|
nestedSignalMap.delete(key)
|
|
805
|
+
// Notify parent so subscribers see the key removal
|
|
806
|
+
const parentSignal = arrayParentSignalMap.get(wrapped) || (wrapped as any)._parentSignal
|
|
807
|
+
if (parentSignal && typeof (parentSignal as any).trigger === 'function') {
|
|
808
|
+
;(parentSignal as Signal<any>).trigger()
|
|
809
|
+
}
|
|
797
810
|
}
|
|
798
811
|
|
|
799
812
|
// Delete from target
|