@bitstillery/mithril 3.1.2 → 3.2.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 +5 -5
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 (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.2.0",
|
|
4
4
|
"description": "Mithril + Signals, Store and SSR",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bitstillery",
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"test": "bun test",
|
|
57
57
|
"prepublishOnly": "bun run lint:ts",
|
|
58
58
|
"pack:dry-run": "npm pack --dry-run",
|
|
59
|
-
"release": "
|
|
60
|
-
"release:patch": "
|
|
61
|
-
"release:minor": "
|
|
62
|
-
"release:major": "
|
|
59
|
+
"release": "./scripts/release.sh",
|
|
60
|
+
"release:patch": "./scripts/release.sh --release-as patch",
|
|
61
|
+
"release:minor": "./scripts/release.sh --release-as minor",
|
|
62
|
+
"release:major": "./scripts/release.sh --release-as major"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"htm": "^3.1.1"
|