mvc-kit 2.12.0 → 2.12.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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# useInstance
|
|
2
|
+
|
|
3
|
+
A low-level React hook that subscribes to any [Subscribable](../types.ts) instance and returns its current state. The caller manages the instance lifecycle — `useInstance` only reads and subscribes; it never creates, initializes, or disposes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Signature
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
function useInstance<S>(subscribable: Subscribable<S>): S
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Parameter | Type | Description |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `subscribable` | `Subscribable<S>` | Any object with `state` and `subscribe(listener)`. [ViewModel](../ViewModel.md), [Model](../Model.md), [Collection](../Collection.md), [Channel](../Channel.md) all qualify. |
|
|
16
|
+
|
|
17
|
+
**Returns:** `S` — the current frozen state of the subscribable. Re-renders the component whenever the state changes.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## How It Works
|
|
22
|
+
|
|
23
|
+
`useInstance` uses a single `useSyncExternalStore` call with a **version counter** for change detection.
|
|
24
|
+
|
|
25
|
+
### Single Combined Subscription
|
|
26
|
+
|
|
27
|
+
A `useRef`-stored `subscribe` function wires up both the state subscription (`subscribable.subscribe`) and, if present (duck-typed check), the async subscription (`subscribable.subscribeAsync`). Both increment a version counter and call `onStoreChange`. The subscribe and getSnapshot functions are stored in a ref and only recreated when the subscribable instance changes, avoiding `useCallback` deps-checking overhead on every render.
|
|
28
|
+
|
|
29
|
+
React detects changes via `Object.is(prevVersion, nextVersion)` on the counter rather than comparing state references. The actual state is read directly from `subscribable.state` in the render body.
|
|
30
|
+
|
|
31
|
+
This means:
|
|
32
|
+
- **ViewModel** — both state and async subscriptions are active. `vm.async.load.loading` changes trigger re-renders.
|
|
33
|
+
- **Collection**, **Model**, **Channel** — only the state subscription is active. These classes don't have `subscribeAsync`, so no async subscription is created.
|
|
34
|
+
|
|
35
|
+
### Why a version counter?
|
|
36
|
+
|
|
37
|
+
Subscribable member notifications (e.g., a Collection changing inside a ViewModel) don't produce a new state reference — the ViewModel's state object is unchanged, only its getters return different results. The version counter ensures React re-renders in these cases without forcing an unnecessary state object spread.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## When to Use
|
|
42
|
+
|
|
43
|
+
Use `useInstance` when you have a **pre-existing instance** that you didn't create and don't own. Common scenarios:
|
|
44
|
+
|
|
45
|
+
- A parent component creates a ViewModel via `useLocal` and passes it to a child
|
|
46
|
+
- A ViewModel exposes a Model as a public property (`vm.model`)
|
|
47
|
+
- You need to read state from a singleton obtained elsewhere
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
function ChildDisplay({ vm }: { vm: ParentViewModel }) {
|
|
51
|
+
const state = useInstance(vm);
|
|
52
|
+
return <p>{state.count}</p>;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### When NOT to Use
|
|
57
|
+
|
|
58
|
+
| Scenario | Use Instead |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Creating a component-scoped ViewModel | [`useLocal`](./use-local.md) — creates, initializes, disposes automatically |
|
|
61
|
+
| Sharing a singleton ViewModel across components | [`useSingleton`](./use-singleton.md) — singleton lifecycle management |
|
|
62
|
+
| Binding a Model with validation/dirty tracking | [`useModel`](./use-model.md) — returns `ModelHandle` with `errors`, `valid`, `dirty` |
|
|
63
|
+
| Creating a Model for per-field children | [`useModelRef`](./use-model.md) — lifecycle without subscription |
|
|
64
|
+
| Binding a single Model field | [`useField`](./use-model.md) — surgical per-field re-renders |
|
|
65
|
+
|
|
66
|
+
`useInstance` returns raw state only. It doesn't provide validation errors, dirty tracking, or any Model-specific features.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### With ViewModel
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
function CounterDisplay({ vm }: { vm: CounterViewModel }) {
|
|
76
|
+
const state = useInstance(vm);
|
|
77
|
+
return <div>{state.count}</div>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
State changes via `vm.set()` trigger re-renders. Computed getters are accessed on the instance, not from the returned state:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
function LocationsList({ vm }: { vm: LocationsViewModel }) {
|
|
85
|
+
const state = useInstance(vm);
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<p>Search: {state.search}</p>
|
|
89
|
+
<p>Filtered: {vm.filtered.length} of {vm.total}</p>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### With ViewModel Async State
|
|
96
|
+
|
|
97
|
+
Because `useInstance` subscribes to `subscribeAsync` on ViewModels, reading `vm.async` in the render body works correctly — the component re-renders when loading or error state changes:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
function AsyncDisplay({ vm }: { vm: DataViewModel }) {
|
|
101
|
+
useInstance(vm);
|
|
102
|
+
const { loading, error } = vm.async.load;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div>
|
|
106
|
+
{loading && <p>Loading...</p>}
|
|
107
|
+
{error && <p className="error">{error}</p>}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The returned state can be ignored (assigned to nothing) if you only need async status and computed getters.
|
|
114
|
+
|
|
115
|
+
### With Model
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
function UserDisplay({ model }: { model: UserModel }) {
|
|
119
|
+
const state = useInstance(model);
|
|
120
|
+
return <p>{state.name}</p>;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
State changes via `model.set()` trigger re-renders. Note: this gives you raw state only — no `errors`, `valid`, or `dirty`. For full form binding, use `useModel` instead.
|
|
125
|
+
|
|
126
|
+
### With Collection
|
|
127
|
+
|
|
128
|
+
Collection state is the items array itself:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
function TodoList({ collection }: { collection: Collection<Todo> }) {
|
|
132
|
+
const items = useInstance(collection);
|
|
133
|
+
return (
|
|
134
|
+
<ul>
|
|
135
|
+
{items.map(item => <li key={item.id}>{item.text}</li>)}
|
|
136
|
+
</ul>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Mutations like `collection.add()`, `collection.remove()`, and `collection.reset()` trigger re-renders.
|
|
142
|
+
|
|
143
|
+
> **Note:** Components should rarely subscribe to a Collection directly. The standard pattern is for a [ViewModel](../ViewModel.md) to mirror collection data into its own state via `subscribeTo`. Direct Collection subscription is appropriate only in low-level or infrastructure components.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Lifecycle Responsibility
|
|
148
|
+
|
|
149
|
+
`useInstance` does **not** manage lifecycle. The caller is responsible for:
|
|
150
|
+
|
|
151
|
+
1. **Creating** the instance before passing it
|
|
152
|
+
2. **Calling `init()`** if the instance requires initialization (ViewModels)
|
|
153
|
+
3. **Calling `dispose()`** when the instance is no longer needed
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
function Parent() {
|
|
157
|
+
const [state, vm] = useLocal(ParentViewModel, { count: 0 });
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div>
|
|
161
|
+
{/* vm is created, initialized, and disposed by useLocal */}
|
|
162
|
+
{/* ChildDisplay just reads from it */}
|
|
163
|
+
<ChildDisplay vm={vm} />
|
|
164
|
+
<button onClick={() => vm.increment()}>+</button>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ChildDisplay({ vm }: { vm: ParentViewModel }) {
|
|
170
|
+
const state = useInstance(vm);
|
|
171
|
+
return <p>{state.count}</p>;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Cleanup
|
|
178
|
+
|
|
179
|
+
The combined subscription cleanup function is called automatically when the component unmounts, unsubscribing from both `subscribe()` and `subscribeAsync()` (if present).
|
|
180
|
+
|
|
181
|
+
After unmount, further state changes on the instance do not cause errors — the subscriptions are simply gone.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## SSR
|
|
186
|
+
|
|
187
|
+
The `useSyncExternalStore` call provides a server snapshot that returns `0` (the initial version counter value). The actual state is read from `subscribable.state` directly.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Best Practices
|
|
192
|
+
|
|
193
|
+
**Use `useInstance` for read-only binding to instances you don't own.** If you're creating the instance, use `useLocal` or `useSingleton` instead.
|
|
194
|
+
|
|
195
|
+
**Access computed getters on the instance, raw state from the return value.**
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
const state = useInstance(vm);
|
|
199
|
+
// state.search — raw state value
|
|
200
|
+
// vm.filtered — computed getter
|
|
201
|
+
// vm.async.load — async status
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Don't forget to init before passing.** If you pass an uninitialized ViewModel to `useInstance`, async tracking won't be active and memoized getters won't work. Always ensure `init()` has been called (or use `useLocal` which calls it automatically).
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { ViewModel } from '../ViewModel';
|
|
6
|
+
import { Model } from '../Model';
|
|
7
|
+
import { Collection } from '../Collection';
|
|
8
|
+
import { useInstance } from './use-instance';
|
|
9
|
+
|
|
10
|
+
interface CounterState {
|
|
11
|
+
count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class CounterVM extends ViewModel<CounterState> {
|
|
15
|
+
constructor(initial = 0) {
|
|
16
|
+
super({ count: initial });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
increment() {
|
|
20
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
decrement() {
|
|
24
|
+
this.set((prev) => ({ count: prev.count - 1 }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function CounterDisplay({ vm }: { vm: CounterVM }) {
|
|
29
|
+
const state = useInstance(vm);
|
|
30
|
+
return <div data-testid="count">{state.count}</div>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('useInstance', () => {
|
|
34
|
+
it('should return the current state', () => {
|
|
35
|
+
const vm = new CounterVM(5);
|
|
36
|
+
render(<CounterDisplay vm={vm} />);
|
|
37
|
+
expect(screen.getByTestId('count').textContent).toBe('5');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should re-render when state changes', () => {
|
|
41
|
+
const vm = new CounterVM(0);
|
|
42
|
+
render(<CounterDisplay vm={vm} />);
|
|
43
|
+
expect(screen.getByTestId('count').textContent).toBe('0');
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
vm.increment();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle multiple state updates', () => {
|
|
53
|
+
const vm = new CounterVM(0);
|
|
54
|
+
render(<CounterDisplay vm={vm} />);
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
vm.increment();
|
|
58
|
+
vm.increment();
|
|
59
|
+
vm.increment();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(screen.getByTestId('count').textContent).toBe('3');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should work with initial state from constructor', () => {
|
|
66
|
+
const vm = new CounterVM(100);
|
|
67
|
+
render(<CounterDisplay vm={vm} />);
|
|
68
|
+
expect(screen.getByTestId('count').textContent).toBe('100');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('with Model', () => {
|
|
72
|
+
interface UserState {
|
|
73
|
+
name: string;
|
|
74
|
+
email: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class UserModel extends Model<UserState> {
|
|
78
|
+
setName(name: string) {
|
|
79
|
+
this.set({ name });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function UserDisplay({ model }: { model: UserModel }) {
|
|
84
|
+
const state = useInstance(model);
|
|
85
|
+
return <div data-testid="name">{state.name}</div>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('should work with Model instances', () => {
|
|
89
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com' });
|
|
90
|
+
render(<UserDisplay model={model} />);
|
|
91
|
+
expect(screen.getByTestId('name').textContent).toBe('John');
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
model.setName('Jane');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(screen.getByTestId('name').textContent).toBe('Jane');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('with Collection', () => {
|
|
102
|
+
interface Todo {
|
|
103
|
+
id: string;
|
|
104
|
+
text: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function TodoList({ collection }: { collection: Collection<Todo> }) {
|
|
108
|
+
const items = useInstance(collection);
|
|
109
|
+
return (
|
|
110
|
+
<ul data-testid="list">
|
|
111
|
+
{items.map(item => <li key={item.id}>{item.text}</li>)}
|
|
112
|
+
</ul>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('should work with Collection instances', () => {
|
|
117
|
+
const collection = new Collection<Todo>([{ id: '1', text: 'Test' }]);
|
|
118
|
+
render(<TodoList collection={collection} />);
|
|
119
|
+
expect(screen.getByTestId('list').children).toHaveLength(1);
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
collection.add({ id: '2', text: 'Another' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(screen.getByTestId('list').children).toHaveLength(2);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not re-subscribe on re-render (stable callback identity)', () => {
|
|
130
|
+
const subscribeSpy = vi.spyOn(CounterVM.prototype, 'subscribe');
|
|
131
|
+
const vm = new CounterVM(0);
|
|
132
|
+
vm.init();
|
|
133
|
+
|
|
134
|
+
const { rerender } = render(<CounterDisplay vm={vm} />);
|
|
135
|
+
|
|
136
|
+
const initialCallCount = subscribeSpy.mock.calls.length;
|
|
137
|
+
|
|
138
|
+
// Force multiple re-renders — subscribe should NOT be called again
|
|
139
|
+
rerender(<CounterDisplay vm={vm} />);
|
|
140
|
+
rerender(<CounterDisplay vm={vm} />);
|
|
141
|
+
rerender(<CounterDisplay vm={vm} />);
|
|
142
|
+
|
|
143
|
+
expect(subscribeSpy.mock.calls.length).toBe(initialCallCount);
|
|
144
|
+
subscribeSpy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('re-renders when subscribable member changes (via version counter)', () => {
|
|
148
|
+
interface DashState {
|
|
149
|
+
label: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class DashVM extends ViewModel<DashState> {
|
|
153
|
+
collection = new Collection<{ id: string; name: string }>();
|
|
154
|
+
|
|
155
|
+
get items() {
|
|
156
|
+
return this.collection.items;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let renderCount = 0;
|
|
161
|
+
function DashDisplay({ vm }: { vm: DashVM }) {
|
|
162
|
+
const state = useInstance(vm);
|
|
163
|
+
renderCount++;
|
|
164
|
+
return (
|
|
165
|
+
<div>
|
|
166
|
+
<span data-testid="label">{state.label}</span>
|
|
167
|
+
<span data-testid="count">{vm.items.length}</span>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const vm = new DashVM({ label: 'test' });
|
|
173
|
+
vm.init();
|
|
174
|
+
renderCount = 0;
|
|
175
|
+
|
|
176
|
+
render(<DashDisplay vm={vm} />);
|
|
177
|
+
const rendersAfterMount = renderCount;
|
|
178
|
+
|
|
179
|
+
act(() => {
|
|
180
|
+
vm.collection.add({ id: '1', name: 'Alice' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Component should have re-rendered even though state reference didn't change
|
|
184
|
+
expect(renderCount).toBeGreaterThan(rendersAfterMount);
|
|
185
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('async subscription', () => {
|
|
189
|
+
function defer<T = void>() {
|
|
190
|
+
let resolve!: (value: T) => void;
|
|
191
|
+
let reject!: (reason?: unknown) => void;
|
|
192
|
+
const promise = new Promise<T>((res, rej) => {
|
|
193
|
+
resolve = res;
|
|
194
|
+
reject = rej;
|
|
195
|
+
});
|
|
196
|
+
return { promise, resolve, reject };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type Deferred = ReturnType<typeof defer<void>>;
|
|
200
|
+
|
|
201
|
+
interface AsyncState {
|
|
202
|
+
value: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class AsyncVM extends ViewModel<AsyncState> {
|
|
206
|
+
private _deferred: Deferred | null = null;
|
|
207
|
+
get deferred() { return this._deferred; }
|
|
208
|
+
|
|
209
|
+
async load(): Promise<void> {
|
|
210
|
+
this._deferred = defer();
|
|
211
|
+
return this._deferred.promise;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function AsyncDisplay({ vm }: { vm: AsyncVM }) {
|
|
216
|
+
useInstance(vm);
|
|
217
|
+
const { loading, error } = vm.async.load;
|
|
218
|
+
return (
|
|
219
|
+
<div>
|
|
220
|
+
<span data-testid="loading">{String(loading)}</span>
|
|
221
|
+
<span data-testid="error">{error ?? 'none'}</span>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it('shows initial async state { loading: false, error: null }', () => {
|
|
227
|
+
const vm = new AsyncVM({ value: '' });
|
|
228
|
+
vm.init();
|
|
229
|
+
|
|
230
|
+
render(<AsyncDisplay vm={vm} />);
|
|
231
|
+
expect(screen.getByTestId('loading').textContent).toBe('false');
|
|
232
|
+
expect(screen.getByTestId('error').textContent).toBe('none');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('re-renders when loading starts', async () => {
|
|
236
|
+
const vm = new AsyncVM({ value: '' });
|
|
237
|
+
vm.init();
|
|
238
|
+
|
|
239
|
+
render(<AsyncDisplay vm={vm} />);
|
|
240
|
+
|
|
241
|
+
await act(async () => {
|
|
242
|
+
vm.load();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
246
|
+
expect(screen.getByTestId('error').textContent).toBe('none');
|
|
247
|
+
|
|
248
|
+
await act(async () => {
|
|
249
|
+
vm.deferred!.resolve();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('re-renders when loading completes', async () => {
|
|
254
|
+
const vm = new AsyncVM({ value: '' });
|
|
255
|
+
vm.init();
|
|
256
|
+
|
|
257
|
+
render(<AsyncDisplay vm={vm} />);
|
|
258
|
+
|
|
259
|
+
let d: Deferred;
|
|
260
|
+
await act(async () => {
|
|
261
|
+
vm.load();
|
|
262
|
+
d = vm.deferred!;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
d.resolve();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(screen.getByTestId('loading').textContent).toBe('false');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('re-renders on async error', async () => {
|
|
275
|
+
class FailVM extends ViewModel<AsyncState> {
|
|
276
|
+
async load(): Promise<void> {
|
|
277
|
+
throw new Error('Network failed');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function FailDisplay({ vm }: { vm: FailVM }) {
|
|
282
|
+
useInstance(vm);
|
|
283
|
+
const { loading, error } = vm.async.load;
|
|
284
|
+
return (
|
|
285
|
+
<div>
|
|
286
|
+
<span data-testid="loading">{String(loading)}</span>
|
|
287
|
+
<span data-testid="error">{error ?? 'none'}</span>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const vm = new FailVM({ value: '' });
|
|
293
|
+
vm.init();
|
|
294
|
+
|
|
295
|
+
render(<FailDisplay vm={vm} />);
|
|
296
|
+
|
|
297
|
+
await act(async () => {
|
|
298
|
+
await vm.load().catch(() => {});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(screen.getByTestId('loading').textContent).toBe('false');
|
|
302
|
+
expect(screen.getByTestId('error').textContent).toBe('Network failed');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('does not affect non-ViewModel subscribables', () => {
|
|
306
|
+
// Collection has no subscribeAsync — should work normally
|
|
307
|
+
const collection = new Collection<{ id: string; text: string }>([
|
|
308
|
+
{ id: '1', text: 'Test' },
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
function CollectionDisplay({ col }: { col: Collection<{ id: string; text: string }> }) {
|
|
312
|
+
const items = useInstance(col);
|
|
313
|
+
return <div data-testid="count">{items.length}</div>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
render(<CollectionDisplay col={collection} />);
|
|
317
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
318
|
+
|
|
319
|
+
act(() => {
|
|
320
|
+
collection.add({ id: '2', text: 'Another' });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(screen.getByTestId('count').textContent).toBe('2');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('cleans up async subscription on unmount', async () => {
|
|
327
|
+
class SaveVM extends ViewModel<AsyncState> {
|
|
328
|
+
async save(): Promise<void> {
|
|
329
|
+
await Promise.resolve();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function SaveDisplay({ vm }: { vm: SaveVM }) {
|
|
334
|
+
useInstance(vm);
|
|
335
|
+
return <div data-testid="ok">ok</div>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const vm = new SaveVM({ value: '' });
|
|
339
|
+
vm.init();
|
|
340
|
+
|
|
341
|
+
const { unmount } = render(<SaveDisplay vm={vm} />);
|
|
342
|
+
unmount();
|
|
343
|
+
|
|
344
|
+
// Should not throw after unmount
|
|
345
|
+
await act(async () => {
|
|
346
|
+
await vm.save();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useSyncExternalStore, useRef } from 'react';
|
|
2
|
+
import type { Subscribable } from '../types';
|
|
3
|
+
|
|
4
|
+
function hasAsyncSubscription(obj: unknown): obj is { subscribeAsync(cb: () => void): () => void } {
|
|
5
|
+
return (
|
|
6
|
+
obj !== null &&
|
|
7
|
+
typeof obj === 'object' &&
|
|
8
|
+
typeof (obj as any).subscribeAsync === 'function'
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SERVER_SNAPSHOT = () => 0;
|
|
13
|
+
|
|
14
|
+
interface InstanceRef<S> {
|
|
15
|
+
version: number;
|
|
16
|
+
subscribable: Subscribable<S>;
|
|
17
|
+
subscribe: (onStoreChange: () => void) => () => void;
|
|
18
|
+
getSnapshot: () => number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to an existing Subscribable instance.
|
|
23
|
+
* No ownership - caller manages the instance lifecycle.
|
|
24
|
+
*
|
|
25
|
+
* If the instance has a `subscribeAsync` method (duck-typed),
|
|
26
|
+
* a combined subscription ensures async state changes also
|
|
27
|
+
* trigger React re-renders.
|
|
28
|
+
*/
|
|
29
|
+
export function useInstance<S>(subscribable: Subscribable<S>): S {
|
|
30
|
+
const ref = useRef<InstanceRef<S> | null>(null);
|
|
31
|
+
|
|
32
|
+
if (!ref.current || ref.current.subscribable !== subscribable) {
|
|
33
|
+
const version = { current: ref.current?.version ?? 0 };
|
|
34
|
+
ref.current = {
|
|
35
|
+
version: version.current,
|
|
36
|
+
subscribable,
|
|
37
|
+
subscribe: (onStoreChange: () => void) => {
|
|
38
|
+
const unsub1 = subscribable.subscribe(() => {
|
|
39
|
+
version.current++;
|
|
40
|
+
ref.current!.version = version.current;
|
|
41
|
+
onStoreChange();
|
|
42
|
+
});
|
|
43
|
+
let unsub2: (() => void) | undefined;
|
|
44
|
+
if (hasAsyncSubscription(subscribable)) {
|
|
45
|
+
unsub2 = subscribable.subscribeAsync(() => {
|
|
46
|
+
version.current++;
|
|
47
|
+
ref.current!.version = version.current;
|
|
48
|
+
onStoreChange();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return () => { unsub1(); unsub2?.(); };
|
|
52
|
+
},
|
|
53
|
+
getSnapshot: () => version.current,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
|
|
58
|
+
|
|
59
|
+
return subscribable.state;
|
|
60
|
+
}
|