@bitstillery/mithril 3.0.1-AA → 3.0.3

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 CHANGED
@@ -1,18 +1,30 @@
1
1
  # @bitstillery/mithril
2
2
 
3
- > Mithril.js with **fine-grained reactivity**, **SSR hydration**, and **zero-dependency signals**.
3
+ Mithril.js fork with fine-grained reactivity, SSR hydration, and built-in signals. Drop-in compatible with Mithril v2.x.
4
4
 
5
- [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ ```bash
6
+ bun add @bitstillery/mithril
7
+ ```
8
+
9
+ ## Why
6
10
 
7
- ## The Problem
11
+ Mithril.js uses global `m.redraw()` — one state change re-renders **everything**. This fork adds component-level reactivity via signals so only affected components update.
8
12
 
9
- Mithril.js uses global `m.redraw()`—one state change updates **all** components. This fork adds fine-grained reactivity: only components using changed state re-render.
13
+ | Feature | Original | This Fork |
14
+ | ---------------- | ------------------- | -------------------------------- |
15
+ | Reactivity | Global `m.redraw()` | Fine-grained component updates |
16
+ | State Management | Manual redraw calls | Signals with automatic tracking |
17
+ | SSR | No hydration | State serialization + hydration |
18
+ | TypeScript | Community types | Native |
19
+ | Stream / request | Built-in | Removed (use signals, `fetch()`) |
10
20
 
11
- ## What Makes This Different
21
+ Signals are opt-in. Existing Mithril code works unchanged.
12
22
 
13
- ### Signals
23
+ **Docs**: [mithril.garage44.org](https://mithril.garage44.org)
14
24
 
15
- Fine-grained reactivity primitives with automatic dependency tracking. Zero-dependency implementation—no Preact Signals or other packages.
25
+ ## Signals
26
+
27
+ Zero-dependency reactive primitives with automatic dependency tracking:
16
28
 
17
29
  ```typescript
18
30
  import {signal, computed, effect} from '@bitstillery/mithril'
@@ -20,71 +32,36 @@ import {signal, computed, effect} from '@bitstillery/mithril'
20
32
  const count = signal(0)
21
33
  const doubled = computed(() => count() * 2)
22
34
 
23
- effect(() => {
24
- console.log(`Count: ${count()}, Doubled: ${doubled()}`)
25
- })
26
-
27
- count(5) // Logs: Count: 5, Doubled: 10
35
+ effect(() => console.log(`${count()} × 2 = ${doubled()}`))
36
+ count(5) // Logs: 5 × 2 = 10
28
37
  ```
29
38
 
30
- ### Signal State
39
+ ## Proxy State
31
40
 
32
- Proxy-based reactive state that makes signals developer-friendly. Automatic dependency tracking with no manual redraw calls.
41
+ Wraps signals in a proxy for ergonomic access. Components automatically track which properties they read and only re-render when those change.
33
42
 
34
43
  ```tsx
35
- import {state} from '@bitstillery/mithril'
36
-
37
- const $s = state(
38
- {
39
- count: 0,
40
- user: {name: 'John'},
41
- todos: [],
42
- totalTodos: () => $s.todos.length, // Computed
43
- },
44
- 'my.state',
45
- ) // Name required for SSR serialization
46
-
47
- // Component only re-renders when $s.count changes
44
+ import m, {state, MithrilComponent} from '@bitstillery/mithril'
45
+
46
+ const $s = state({count: 0, todos: [], totalTodos: () => $s.todos.length}, 'app')
47
+
48
48
  class Counter extends MithrilComponent {
49
49
  view() {
50
50
  return (
51
51
  <div>
52
- <p>Count: {$s.count}</p>
53
- <button onclick={() => $s.count++}>Increment</button>
52
+ <p>{$s.count}</p>
53
+ <button onclick={() => $s.count++}>+</button>
54
54
  </div>
55
55
  )
56
56
  }
57
57
  }
58
- ```
59
58
 
60
- ### Persistent Store
61
-
62
- State persistence with automatic serialization. The `Store` class wraps `state()` with localStorage/sessionStorage support, seamlessly integrating with SSR hydration.
63
-
64
- ```typescript
65
- import {Store} from '@bitstillery/mithril'
66
-
67
- const store = new Store<{user: {name: string}; preferences: Record<string, any>}>()
68
-
69
- // Define what persists vs what's volatile
70
- store.blueprint(
71
- {user: {name: ''}, preferences: {}},
72
- {
73
- user: {name: ''}, // Persistent
74
- preferences: {}, // Persistent
75
- },
76
- )
77
-
78
- // Load from storage, or initialize with defaults
79
- store.load({user: {name: 'John'}, preferences: {theme: 'dark'}})
80
-
81
- // State is reactive and automatically saves on changes
82
- store.state.user.name = 'Jane' // Auto-saves to localStorage
59
+ m.mount(document.body, Counter)
83
60
  ```
84
61
 
85
- ### SSR Hydration
62
+ The second argument to `state()` is a name used for SSR serialization.
86
63
 
87
- Server-side rendering with state preservation. `renderToString` automatically serializes state; restore it on the client before mounting.
64
+ ## SSR Hydration
88
65
 
89
66
  ```typescript
90
67
  // Server
@@ -94,57 +71,31 @@ const {html, state} = await m.renderToString(App)
94
71
  // Client
95
72
  import {deserializeAllStates} from '@bitstillery/mithril'
96
73
 
97
- const ssrState = document.getElementById('__SSR_STATE__')
98
- if (ssrState?.textContent) {
99
- deserializeAllStates(JSON.parse(ssrState.textContent))
100
- }
74
+ const el = document.getElementById('__SSR_STATE__')
75
+ if (el?.textContent) deserializeAllStates(JSON.parse(el.textContent))
101
76
  m.mount(root, App)
102
77
  ```
103
78
 
104
- ## The Complete Picture
105
-
106
- These features build on each other: signals provide the foundation for fine-grained reactivity, proxy-based state makes them developer-friendly, `Store` adds persistence for localStorage/sessionStorage, and SSR hydration enables search-engine friendly websites. The result? State that "just works"—from initial render through hydration, user interactions, and page refreshes—all while maintaining Mithril's familiar API.
107
-
108
- ## Quick Start
109
-
110
- ```bash
111
- bun add @bitstillery/mithril
112
- ```
113
-
114
- ```tsx
115
- import m, {state, MithrilComponent} from '@bitstillery/mithril'
79
+ ## Persistent Store
116
80
 
117
- const $s = state({count: 0}, 'app.state') // Name required for SSR
81
+ `Store` wraps `state()` with localStorage/sessionStorage. Define a blueprint with defaults and which keys persist:
118
82
 
119
- class App extends MithrilComponent {
120
- view() {
121
- return (
122
- <div>
123
- <p>Count: {$s.count}</p>
124
- <button onclick={() => $s.count++}>Increment</button>
125
- </div>
126
- )
127
- }
128
- }
83
+ ```typescript
84
+ import {Store} from '@bitstillery/mithril'
129
85
 
130
- m.mount(document.body, App)
86
+ const store = new Store<{user: {name: string}; preferences: Record<string, any>}>()
87
+ store.blueprint(
88
+ {user: {name: ''}, preferences: {}},
89
+ {user: {name: ''}, preferences: {}}, // Keys here persist to storage
90
+ )
91
+ store.load({user: {name: 'John'}, preferences: {theme: 'dark'}})
92
+ store.state.user.name = 'Jane' // Auto-saves
131
93
  ```
132
94
 
133
95
  ## Examples
134
96
 
135
- - **SSR**: [`examples/ssr/`](examples/ssr/) - Server-side rendering with hydration
136
- - **State**: [`examples/state/`](examples/state/) - Signals, state management, and Store persistence patterns
137
-
138
- ## Differences from Mithril.js
139
-
140
- | Feature | Original | This Fork |
141
- | ---------------- | ------------------- | ------------------------------- |
142
- | Reactivity | Global `m.redraw()` | Fine-grained component updates |
143
- | State Management | Manual redraw calls | Signals with automatic tracking |
144
- | SSR Hydration | State loss | Proper state preservation |
145
- | TypeScript | Community types | Native TypeScript |
146
-
147
- **100% API compatible** with Mithril.js v2.x. Signals are opt-in.
97
+ - [`examples/ssr/`](examples/ssr/) — Server-side rendering with hydration
98
+ - [`examples/state/`](examples/state/) — Signals, state, and Store patterns
148
99
 
149
100
  ## Development
150
101
 
@@ -155,31 +106,8 @@ bun test
155
106
 
156
107
  ## License
157
108
 
158
- MIT License - see [LICENSE](LICENSE) file for details.
109
+ MIT — see [LICENSE](LICENSE).
159
110
 
160
111
  ## Credits
161
112
 
162
- Mithril.js was originally written by Leo Horie, but it is where it is today thanks to the hard work and great ideas of many people.
163
-
164
- Special thanks to:
165
-
166
- - Pat Cavit, who exposed most of the public API for Mithril.js 1.0, brought in test coverage and automated the publishing process
167
- - Claudia Meadows, who brought in linting, modernized the test suite and has been a strong voice in design discussions
168
- - Zoli Kahan, who replaced the original Promise implementation with one that actually worked properly
169
- - Alec Embke, who single-handedly wrote the JSON-P implementation
170
- - Barney Carroll, who suggested many great ideas and relentlessly pushed Mithril.js to the limit to uncover design issues prior to Mithril.js 1.0
171
- - Dominic Gannaway, who offered insanely meticulous technical insight into rendering performance
172
- - Boris Letocha, whose search space reduction algorithm is the basis for Mithril.js' virtual DOM engine
173
- - Joel Richard, whose monomorphic virtual DOM structure is the basis for Mithril.js' vnode implementation
174
- - Simon Friis Vindum, whose open source work was an inspiration to many design decisions for Mithril.js 1.0
175
- - Boris Kaul, for his awesome work on the benchmarking tools used to develop Mithril.js
176
- - Leon Sorokin, for writing a DOM instrumentation tool that helped improve performance in Mithril.js 1.0
177
- - Jordan Walke, whose work on React was prior art to the implementation of keys in Mithril.js
178
- - Pierre-Yves Gérardy, who consistently makes high quality contributions
179
- - Gyandeep Singh, who contributed significant IE performance improvements
180
-
181
- Other people who also deserve recognition:
182
-
183
- - Arthur Clemens - creator of [Polythene](https://github.com/ArthurClemens/Polythene) and the [HTML-to-Mithril converter](https://arthurclemens.github.io/mithril-template-converter/index.html)
184
- - Stephan Hoyer - creator of [mithril-node-render](https://github.com/StephanHoyer/mithril-node-render), [mithril-query](https://github.com/StephanHoyer/mithril-query) and [mithril-source-hint](https://github.com/StephanHoyer/mithril-source-hint)
185
- - the countless people who have reported and fixed bugs, participated in discussions, and helped promote Mithril.js
113
+ Originally created by Leo Horie. See the [Mithril.js contributors](https://github.com/MithrilJS/mithril.js/graphs/contributors) for the many people who made Mithril what it is.
@@ -1,5 +1,6 @@
1
1
  import Vnode from '../render/vnode'
2
2
  import {getSignalComponents, type Signal} from '../signal'
3
+ import {getStateMaps} from '../render/render'
3
4
 
4
5
  import type {ComponentType, Children, Vnode as VnodeType} from '../render/vnode'
5
6
 
@@ -54,11 +55,9 @@ export default function mountRedrawFactory(render: Render, schedule: Schedule, c
54
55
  function redrawComponent(componentOrState: ComponentType) {
55
56
  // componentOrState might be vnode.state (from signal tracking) or component object
56
57
  // Try to find the actual component object if it's vnode.state
57
- let component = componentOrState
58
- const stateToComponentMap = (globalThis as any).__mithrilStateToComponent as WeakMap<any, ComponentType> | undefined
59
- if (stateToComponentMap && stateToComponentMap.has(componentOrState)) {
60
- component = stateToComponentMap.get(componentOrState)!
61
- }
58
+ const {stateToComponentMap, stateToDomMap, stateToVnodeMap} = getStateMaps()
59
+ const resolved = stateToComponentMap.get(componentOrState)
60
+ const component = resolved !== undefined ? resolved : componentOrState
62
61
 
63
62
  // First try: find element in componentToElement (for m.mount components)
64
63
  // Check this first to ensure synchronous redraws for m.mount components
@@ -74,14 +73,31 @@ export default function mountRedrawFactory(render: Render, schedule: Schedule, c
74
73
  }
75
74
  }
76
75
 
77
- // Second try: find DOM element directly from component state (for routed components)
76
+ // Second try: targeted redraw for nested components (stateToDomMap path)
78
77
  // Only check this if componentToElement didn't find anything (not an m.mount component)
79
- const stateToDomMap = (globalThis as any).__mithrilStateToDom as WeakMap<any, Element> | undefined
80
- if (stateToDomMap && stateToDomMap.has(componentOrState)) {
81
- // For routed components, always use global redraw to ensure RouterRoot re-renders correctly
82
- // RouterRoot needs currentResolver and component to be set (from route resolution)
83
- // A direct redraw might use stale route state, so we trigger a full sync instead
84
- // This ensures RouterRoot re-renders with the current route, preserving Layout
78
+ const nestedElement = stateToDomMap.get(componentOrState)
79
+ if (nestedElement !== undefined) {
80
+ const vnodeInfo = stateToVnodeMap.get(componentOrState)
81
+ if (nestedElement?.isConnected && component != null && vnodeInfo != null) {
82
+ const parent = nestedElement.parentElement
83
+ const oldVnodes = parent != null ? (parent as any).vnodes : null
84
+ if (parent != null && Array.isArray(oldVnodes) && oldVnodes.length > 0) {
85
+ const i = oldVnodes.findIndex((v: any) => v?.state === componentOrState)
86
+ if (i >= 0) {
87
+ const {key, attrs} = vnodeInfo
88
+ const newVnodes = [...oldVnodes]
89
+ newVnodes[i] = Vnode(component, key ?? null, attrs ?? null, null, null, null)
90
+ try {
91
+ render(parent, newVnodes, redraw)
92
+ return
93
+ } catch (e) {
94
+ console.error(e)
95
+ // Fall through to full sync on error
96
+ }
97
+ }
98
+ }
99
+ }
100
+ // Fallback: full sync for RouterRoot correctness or when targeted path fails
85
101
  if (!pending) {
86
102
  pending = true
87
103
  schedule(function () {
@@ -117,6 +133,107 @@ export default function mountRedrawFactory(render: Render, schedule: Schedule, c
117
133
  }
118
134
  }
119
135
 
136
+ function redrawComponents(components: Set<ComponentType>) {
137
+ const {stateToComponentMap, stateToDomMap, stateToVnodeMap} = getStateMaps()
138
+ const mountRoots: ComponentType[] = []
139
+ const nested = new Set<ComponentType>()
140
+
141
+ for (const c of components) {
142
+ const component = stateToComponentMap.get(c) ?? c
143
+ if (componentToElement.get(component)) {
144
+ mountRoots.push(c)
145
+ } else {
146
+ nested.add(c)
147
+ }
148
+ }
149
+
150
+ for (const c of mountRoots) {
151
+ redrawComponent(c)
152
+ }
153
+
154
+ if (nested.size === 0) return
155
+
156
+ // Group nested components by parent element
157
+ const parentToStates = new Map<Element, Set<ComponentType>>()
158
+ for (const state of nested) {
159
+ const el = stateToDomMap.get(state)
160
+ const parent = el?.parentElement
161
+ if (!parent || !el?.isConnected) {
162
+ // Can't do targeted redraw, fall back to full sync
163
+ if (!pending) {
164
+ pending = true
165
+ schedule(function () {
166
+ pending = false
167
+ sync()
168
+ })
169
+ }
170
+ return
171
+ }
172
+ let set = parentToStates.get(parent)
173
+ if (!set) {
174
+ set = new Set()
175
+ parentToStates.set(parent, set)
176
+ }
177
+ set.add(state)
178
+ }
179
+
180
+ for (const [parent, states] of parentToStates) {
181
+ const oldVnodes = (parent as any).vnodes
182
+ if (!Array.isArray(oldVnodes) || oldVnodes.length === 0) {
183
+ if (!pending) {
184
+ pending = true
185
+ schedule(function () {
186
+ pending = false
187
+ sync()
188
+ })
189
+ }
190
+ return
191
+ }
192
+
193
+ const newVnodes = [...oldVnodes]
194
+ let allFound = true
195
+ for (const state of states) {
196
+ const i = oldVnodes.findIndex((v: any) => v?.state === state)
197
+ if (i < 0) {
198
+ allFound = false
199
+ break
200
+ }
201
+ const component = stateToComponentMap.get(state) ?? state
202
+ const vnodeInfo = stateToVnodeMap.get(state)
203
+ if (!vnodeInfo) {
204
+ allFound = false
205
+ break
206
+ }
207
+ const {key, attrs} = vnodeInfo
208
+ newVnodes[i] = Vnode(component, key ?? null, attrs ?? null, null, null, null)
209
+ }
210
+
211
+ if (!allFound) {
212
+ if (!pending) {
213
+ pending = true
214
+ schedule(function () {
215
+ pending = false
216
+ sync()
217
+ })
218
+ }
219
+ return
220
+ }
221
+
222
+ try {
223
+ render(parent, newVnodes, redraw)
224
+ } catch (e) {
225
+ console.error(e)
226
+ if (!pending) {
227
+ pending = true
228
+ schedule(function () {
229
+ pending = false
230
+ sync()
231
+ })
232
+ }
233
+ }
234
+ }
235
+ }
236
+
120
237
  function redraw(component?: ComponentType) {
121
238
  // Component-level redraw
122
239
  if (component !== undefined) {
@@ -135,6 +252,7 @@ export default function mountRedrawFactory(render: Render, schedule: Schedule, c
135
252
  }
136
253
 
137
254
  redraw.sync = sync
255
+ ;(redraw as any).redrawComponents = redrawComponents
138
256
 
139
257
  // Export function to redraw components affected by signal changes
140
258
  ;(redraw as any).signal = function (signal: Signal<any>) {
package/index.ts CHANGED
@@ -70,14 +70,33 @@ m.censor = censor
70
70
  m.nextTick = nextTick
71
71
  m.domFor = domFor
72
72
 
73
- // Set up signal-to-component redraw integration
73
+ // Set up signal-to-component redraw integration with batching.
74
+ // Collects all components needing redraw in the current tick, then flushes once via queueMicrotask.
75
+ // Avoids N synchronous redraws when many signals fire (e.g. 50% of 800 rows).
76
+ let pendingRedrawComponents = new Set<any>()
77
+ let redrawScheduled = false
78
+
79
+ function flushPendingRedraws() {
80
+ const components = new Set(pendingRedrawComponents)
81
+ pendingRedrawComponents.clear()
82
+ redrawScheduled = false
83
+ if (components.size === 1) {
84
+ m.redraw(components.values().next().value)
85
+ } else if (components.size > 1) {
86
+ const fn = (m.redraw as any).redrawComponents
87
+ if (fn) fn(components)
88
+ else m.redraw()
89
+ }
90
+ }
91
+
74
92
  setSignalRedrawCallback((sig: Signal<any>) => {
75
93
  const components = getSignalComponents(sig)
76
- if (components) {
77
- components.forEach((component) => {
78
- // Use the component-level redraw
79
- m.redraw(component as any)
80
- })
94
+ if (components && components.size > 0) {
95
+ components.forEach((c) => pendingRedrawComponents.add(c))
96
+ if (!redrawScheduled) {
97
+ redrawScheduled = true
98
+ queueMicrotask(flushPendingRedraws)
99
+ }
81
100
  }
82
101
  })
83
102
 
@@ -90,6 +109,7 @@ export {Store} from './store'
90
109
 
91
110
  // Export SSR utilities
92
111
  export {serializeStore, deserializeStore, serializeAllStates, deserializeAllStates} from './render/ssrState'
112
+ export type {DeserializeOptions} from './render/ssrState'
93
113
 
94
114
  // Export SSR request context (for per-request store and state registry)
95
115
  export {getSSRContext, runWithContext, runWithContextAsync, cleanupWatchers} from './ssrContext'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bitstillery/mithril",
3
- "version": "3.0.1-AA",
4
- "description": "Mithril AA (AI Augmented) - Mithril + Signals, Store and SSR",
3
+ "version": "3.0.3",
4
+ "description": "Mithril + Signals, Store and SSR",
5
5
  "license": "MIT",
6
6
  "author": "Bitstillery",
7
7
  "repository": {
@@ -58,9 +58,19 @@
58
58
  "htm": "^3.1.1"
59
59
  },
60
60
  "devDependencies": {
61
+ "@codemirror/lang-html": "^6.4.9",
62
+ "@codemirror/lang-javascript": "^6.2.2",
63
+ "@codemirror/lang-json": "^6.0.2",
64
+ "@codemirror/state": "^6.4.1",
65
+ "@codemirror/theme-one-dark": "^6.1.2",
66
+ "@codemirror/view": "^6.39.1",
61
67
  "@types/bun": "^1.3.9",
62
68
  "@typescript/native-preview": "^7.0.0-dev.20260120.1",
69
+ "codemirror": "^6.0.1",
70
+ "marked": "^15.0.4",
71
+ "marked-gfm-heading-id": "^4.0.0",
63
72
  "oxfmt": "^0.32.0",
64
- "oxlint": "^1.30.0"
73
+ "oxlint": "^1.30.0",
74
+ "tippy.js": "^6.3.7"
65
75
  }
66
76
  }
package/render/render.ts CHANGED
@@ -9,6 +9,15 @@ import cachedAttrsIsStaticMap from './cachedAttrsIsStaticMap'
9
9
 
10
10
  import type {Vnode as VnodeType, Children} from './vnode'
11
11
 
12
+ // Module-level maps for component/state-to-DOM tracking (used by mount-redraw for signal redraws)
13
+ const stateToDomMap = new WeakMap<any, Element>()
14
+ const stateToComponentMap = new WeakMap<any, any>()
15
+ const stateToVnodeMap = new WeakMap<any, {key?: string | number | null; attrs?: Record<string, any>}>()
16
+
17
+ export function getStateMaps() {
18
+ return {stateToDomMap, stateToComponentMap, stateToVnodeMap}
19
+ }
20
+
12
21
  export default function renderFactory() {
13
22
  const nameSpace: Record<string, string> = {
14
23
  svg: 'http://www.w3.org/2000/svg',
@@ -383,8 +392,7 @@ export default function renderFactory() {
383
392
  // Track component for signal dependency tracking
384
393
  // Store mapping from vnode.state to vnode.tag (component object) for redraw
385
394
  if (vnode.state && vnode.tag && !isHydrating) {
386
- ;(globalThis as any).__mithrilStateToComponent = (globalThis as any).__mithrilStateToComponent || new WeakMap()
387
- ;(globalThis as any).__mithrilStateToComponent.set(vnode.state, vnode.tag)
395
+ stateToComponentMap.set(vnode.state, vnode.tag)
388
396
  }
389
397
  // Always track component dependencies for signal tracking (even during hydration)
390
398
  // This allows signals to know which components depend on them
@@ -418,10 +426,10 @@ export default function renderFactory() {
418
426
  vnode.dom = vnode.instance.dom
419
427
  vnode.domSize = vnode.instance.domSize
420
428
 
421
- // Store component's DOM element for fine-grained redraw (not during hydration)
429
+ // Store component's DOM element and vnode info for fine-grained redraw (not during hydration)
422
430
  if (vnode.state && vnode.dom && !isHydrating) {
423
- ;(globalThis as any).__mithrilStateToDom = (globalThis as any).__mithrilStateToDom || new WeakMap()
424
- ;(globalThis as any).__mithrilStateToDom.set(vnode.state, vnode.dom)
431
+ stateToDomMap.set(vnode.state, vnode.dom)
432
+ stateToVnodeMap.set(vnode.state, {key: vnode.key, attrs: vnode.attrs})
425
433
  }
426
434
  } else {
427
435
  vnode.domSize = 0
@@ -697,8 +705,7 @@ export default function renderFactory() {
697
705
  // Track component for signal dependency tracking
698
706
  // Store mapping from vnode.state to vnode.tag (component object) for redraw
699
707
  if (vnode.state && vnode.tag && !isHydrating) {
700
- ;(globalThis as any).__mithrilStateToComponent = (globalThis as any).__mithrilStateToComponent || new WeakMap()
701
- ;(globalThis as any).__mithrilStateToComponent.set(vnode.state, vnode.tag)
708
+ stateToComponentMap.set(vnode.state, vnode.tag)
702
709
  }
703
710
  // Always track component dependencies for signal tracking (even during hydration)
704
711
  // This allows signals to know which components depend on them
@@ -723,10 +730,10 @@ export default function renderFactory() {
723
730
  vnode.dom = vnode.instance.dom
724
731
  vnode.domSize = vnode.instance.domSize
725
732
 
726
- // Store component's DOM element for fine-grained redraw (not during hydration)
733
+ // Store component's DOM element and vnode info for fine-grained redraw (not during hydration)
727
734
  if (vnode.state && vnode.dom && !isHydrating) {
728
- ;(globalThis as any).__mithrilStateToDom = (globalThis as any).__mithrilStateToDom || new WeakMap()
729
- ;(globalThis as any).__mithrilStateToDom.set(vnode.state, vnode.dom)
735
+ stateToDomMap.set(vnode.state, vnode.dom)
736
+ stateToVnodeMap.set(vnode.state, {key: vnode.key, attrs: vnode.attrs})
730
737
  }
731
738
  } else {
732
739
  if (old.instance != null) removeNode(parent, old.instance)
@@ -912,9 +919,12 @@ export default function renderFactory() {
912
919
  }
913
920
 
914
921
  function onremove(vnode: any) {
915
- // Clean up signal dependencies when component is removed
922
+ // Clean up signal dependencies and state maps when component is removed
916
923
  if (typeof vnode.tag !== 'string' && vnode.state != null) {
917
924
  clearComponentDependencies(vnode.state)
925
+ stateToDomMap.delete(vnode.state)
926
+ stateToComponentMap.delete(vnode.state)
927
+ stateToVnodeMap.delete(vnode.state)
918
928
  }
919
929
  if (typeof vnode.tag !== 'string' && typeof vnode.state.onremove === 'function')
920
930
  callHook.call(vnode.state.onremove, vnode)
@@ -326,12 +326,18 @@ function restoreComputedProperties(state: State<any>, initial: any): void {
326
326
  restore(initial, state)
327
327
  }
328
328
 
329
- export function deserializeAllStates(serialized: Record<string, any>): void {
329
+ export interface DeserializeOptions {
330
+ /** Skip deserializing these state instances (e.g. Store state that should come from localStorage) */
331
+ skipStates?: Set<any>
332
+ }
333
+
334
+ export function deserializeAllStates(serialized: Record<string, any>, options?: DeserializeOptions): void {
330
335
  if (!serialized || typeof serialized !== 'object') {
331
336
  return
332
337
  }
333
338
 
334
339
  const registeredStates = getRegisteredStates()
340
+ const skipStates = options?.skipStates
335
341
 
336
342
  // First, deserialize all states
337
343
  for (const [name, serializedState] of Object.entries(serialized)) {
@@ -345,6 +351,10 @@ export function deserializeAllStates(serialized: Record<string, any>): void {
345
351
  continue
346
352
  }
347
353
 
354
+ if (skipStates?.has(entry.state)) {
355
+ continue
356
+ }
357
+
348
358
  try {
349
359
  deserializeStore(entry.state, serializedState)
350
360
  } catch (error) {
@@ -356,6 +366,9 @@ export function deserializeAllStates(serialized: Record<string, any>): void {
356
366
  // After deserializing, restore computed properties from original initial states
357
367
  // This ensures computed properties work after SSR deserialization
358
368
  for (const [name, entry] of registeredStates.entries()) {
369
+ if (skipStates?.has(entry.state)) {
370
+ continue
371
+ }
359
372
  try {
360
373
  restoreComputedProperties(entry.state, entry.initial)
361
374
  } catch (error) {
package/signal.ts CHANGED
@@ -25,15 +25,20 @@ export function getCurrentComponent() {
25
25
  }
26
26
 
27
27
  export function trackComponentSignal(component: any, signal: Signal<any>) {
28
- if (!componentSignalMap.has(component)) {
29
- componentSignalMap.set(component, new Set())
28
+ let set = componentSignalMap.get(component)
29
+ if (!set) {
30
+ set = new Set()
31
+ componentSignalMap.set(component, set)
30
32
  }
31
- componentSignalMap.get(component)!.add(signal)
33
+ if (set.has(signal)) return
34
+ set.add(signal)
32
35
 
33
- if (!signalComponentMap.has(signal)) {
34
- signalComponentMap.set(signal, new Set())
36
+ let compSet = signalComponentMap.get(signal)
37
+ if (!compSet) {
38
+ compSet = new Set()
39
+ signalComponentMap.set(signal, compSet)
35
40
  }
36
- signalComponentMap.get(signal)!.add(component)
41
+ compSet.add(component)
37
42
  }
38
43
 
39
44
  export function getComponentSignals(component: any): Set<Signal<any>> | undefined {
package/store.ts CHANGED
@@ -144,9 +144,13 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
144
144
  private lookup_verify_interval: number | null = null
145
145
  private lookup_ttl: number
146
146
  private computedPropertiesSetup?: () => void
147
+ private storageKey: string
148
+ private tabStorageKey: string
147
149
 
148
- constructor(options: {lookup_ttl?: number} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
150
+ constructor(options: {lookup_ttl?: number; storageKey?: string; tabStorageKey?: string} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
149
151
  this.lookup_ttl = options.lookup_ttl || DEFAULT_LOOKUP_TTL
152
+ this.storageKey = options.storageKey ?? 'store'
153
+ this.tabStorageKey = options.tabStorageKey ?? this.storageKey
150
154
  // Initialize with empty state, will be loaded later (ADR-0013: defer computeds until ready() is called)
151
155
  const instanceName = `store.instance.${storeInstanceCounter++}`
152
156
  this.stateInstance = state({} as T, instanceName, {deferComputed: true})
@@ -271,8 +275,8 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
271
275
 
272
276
  load(saved: Partial<T>, temporary: Partial<T>, tab: Partial<T> = {} as Partial<T>, session: Partial<T> = {} as Partial<T>) {
273
277
  const restored_state = {
274
- tab: this.get_tab_storage('store'),
275
- store: this.get('store'),
278
+ tab: this.get_tab_storage(this.tabStorageKey),
279
+ store: this.get(this.storageKey),
276
280
  }
277
281
 
278
282
  this.templates = {
@@ -381,7 +385,7 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
381
385
 
382
386
  // Save to localStorage (saved state)
383
387
  if (saveSaved && this.templates.saved) {
384
- this.set('store', this.blueprint(statePlain, copy_object(this.templates.saved)))
388
+ this.set(this.storageKey, this.blueprint(statePlain, copy_object(this.templates.saved)))
385
389
  }
386
390
 
387
391
  // Save to sessionStorage (tab state)
@@ -397,18 +401,18 @@ export class Store<T extends Record<string, any> = Record<string, any>> {
397
401
  if (isState(tabState)) {
398
402
  const tabPlain = serializeStore(tabState)
399
403
  // blueprint expects both arguments to have the same structure
400
- this.set_tab('store', this.blueprint(tabPlain, copy_object(tabTemplate)))
404
+ this.set_tab(this.tabStorageKey, this.blueprint(tabPlain, copy_object(tabTemplate)))
401
405
  } else {
402
406
  // Plain object tab
403
- this.set_tab('store', this.blueprint(tabState, copy_object(tabTemplate)))
407
+ this.set_tab(this.tabStorageKey, this.blueprint(tabState, copy_object(tabTemplate)))
404
408
  }
405
409
  } else {
406
410
  // No tab state - save empty tab based on template structure
407
411
  const tabTemplate = (this.templates.tab as any).tab || this.templates.tab
408
412
  if (tabTemplate && Object.keys(tabTemplate).length > 0) {
409
- this.set_tab('store', this.blueprint({} as any, copy_object(tabTemplate)))
413
+ this.set_tab(this.tabStorageKey, this.blueprint({} as any, copy_object(tabTemplate)))
410
414
  } else {
411
- this.set_tab('store', {})
415
+ this.set_tab(this.tabStorageKey, {})
412
416
  }
413
417
  }
414
418
  }