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,69 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import type { Subscribable, Disposable } from '../types';
|
|
3
|
+
import { singleton } from '../singleton';
|
|
4
|
+
import { useInstance } from './use-instance';
|
|
5
|
+
import { isSubscribable, isSubscribeOnly, isInitializable } from './guards';
|
|
6
|
+
import { useSubscribeOnly } from './use-subscribe-only';
|
|
7
|
+
import type { StateOf } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get singleton Subscribable with DEFAULT_STATE — no constructor args needed.
|
|
11
|
+
*/
|
|
12
|
+
export function useSingleton<
|
|
13
|
+
T extends Subscribable<S> & Disposable, S = StateOf<T>,
|
|
14
|
+
>(Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown }): [S, T];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get singleton Disposable with DEFAULT_STATE — no constructor args needed.
|
|
18
|
+
*/
|
|
19
|
+
export function useSingleton<T extends Disposable>(
|
|
20
|
+
Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
|
|
21
|
+
): T;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get singleton Subscribable instance and subscribe to its state.
|
|
25
|
+
* Returns [state, instance] tuple.
|
|
26
|
+
*/
|
|
27
|
+
export function useSingleton<
|
|
28
|
+
T extends Subscribable<S> & Disposable,
|
|
29
|
+
S = StateOf<T>,
|
|
30
|
+
Args extends unknown[] = unknown[]
|
|
31
|
+
>(
|
|
32
|
+
Class: new (...args: Args) => T,
|
|
33
|
+
...args: Args
|
|
34
|
+
): [S, T];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get singleton Disposable instance (non-Subscribable).
|
|
38
|
+
* Returns the instance directly.
|
|
39
|
+
*/
|
|
40
|
+
export function useSingleton<T extends Disposable, Args extends unknown[] = unknown[]>(
|
|
41
|
+
Class: new (...args: Args) => T,
|
|
42
|
+
...args: Args
|
|
43
|
+
): T;
|
|
44
|
+
|
|
45
|
+
// Implementation
|
|
46
|
+
export function useSingleton<T extends Disposable, S = StateOf<T>, Args extends unknown[] = unknown[]>(
|
|
47
|
+
Class: new (...args: Args) => T,
|
|
48
|
+
...args: Args
|
|
49
|
+
): [S, T] | T {
|
|
50
|
+
const instance = singleton(Class, ...args);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (isInitializable(instance)) {
|
|
54
|
+
instance.init();
|
|
55
|
+
}
|
|
56
|
+
}, [instance]);
|
|
57
|
+
|
|
58
|
+
if (isSubscribable(instance)) {
|
|
59
|
+
const state = useInstance(instance) as S;
|
|
60
|
+
return [state, instance];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isSubscribeOnly(instance)) {
|
|
64
|
+
useSubscribeOnly(instance);
|
|
65
|
+
return instance;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return instance;
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useSyncExternalStore, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SubscribeOnlyRef {
|
|
4
|
+
target: { subscribe(cb: () => void): () => void };
|
|
5
|
+
version: number;
|
|
6
|
+
subscribe: (onStoreChange: () => void) => () => void;
|
|
7
|
+
getSnapshot: () => number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SERVER_SNAPSHOT = () => 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to a notification-only object (has subscribe() but no state).
|
|
14
|
+
* Triggers React re-renders via version counter when the target notifies.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export function useSubscribeOnly(
|
|
18
|
+
target: { subscribe(cb: () => void): () => void },
|
|
19
|
+
): void {
|
|
20
|
+
const ref = useRef<SubscribeOnlyRef | null>(null);
|
|
21
|
+
|
|
22
|
+
if (!ref.current || ref.current.target !== target) {
|
|
23
|
+
const version = { current: ref.current?.version ?? 0 };
|
|
24
|
+
ref.current = {
|
|
25
|
+
target,
|
|
26
|
+
version: version.current,
|
|
27
|
+
subscribe: (onStoreChange: () => void) => {
|
|
28
|
+
return target.subscribe(() => {
|
|
29
|
+
version.current++;
|
|
30
|
+
ref.current!.version = version.current;
|
|
31
|
+
onStoreChange();
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
getSnapshot: () => version.current,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
|
|
39
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# useTeardown
|
|
2
|
+
|
|
3
|
+
Teardown one or more [singleton](../singleton.md) instances when the component unmounts. Uses deferred disposal to safely handle React StrictMode's double-mount cycle.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Signature
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
function useTeardown(
|
|
11
|
+
...Classes: Array<new (...args: unknown[]) => Disposable>
|
|
12
|
+
): void
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Parameter | Type | Description |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `...Classes` | `Array<new (...args: unknown[]) => Disposable>` | One or more singleton classes to tear down on unmount. Any class registered via `singleton()` that implements the [Disposable](../types.ts) interface — [ViewModel](../ViewModel.md), [Service](../Service.md), [Collection](../Collection.md), [EventBus](../EventBus.md), [Channel](../Channel.md). |
|
|
18
|
+
|
|
19
|
+
**Returns:** `void`
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## How It Works
|
|
24
|
+
|
|
25
|
+
`useTeardown` calls [`teardown(Class)`](../singleton.md) for each class when the component unmounts. Disposal is **deferred** via `setTimeout(0)` to survive React StrictMode's unmount-then-remount cycle.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Mount → mountedRef = true
|
|
29
|
+
StrictMode unmount → mountedRef = false → setTimeout scheduled
|
|
30
|
+
StrictMode remount → mountedRef = true (before timeout fires)
|
|
31
|
+
Timeout fires → mountedRef is true → teardown skipped ✓
|
|
32
|
+
|
|
33
|
+
Real unmount → mountedRef = false → setTimeout scheduled
|
|
34
|
+
Timeout fires → mountedRef is still false → teardown(Class) called ✓
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Key behaviors:
|
|
38
|
+
|
|
39
|
+
- **Deferred disposal** — `teardown()` runs in a `setTimeout(0)`, not synchronously in the cleanup function. This gives StrictMode's remount a chance to set `mountedRef` back to `true` before the timeout fires.
|
|
40
|
+
- **Idempotent** — If the singleton was already disposed or never created, `teardown()` is a no-op. No errors thrown.
|
|
41
|
+
- **Multiple classes** — Pass any number of classes as arguments. Each is torn down independently.
|
|
42
|
+
- **One-time effect** — The `useEffect` runs once on mount (empty deps array). Cleanup runs once on unmount.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## When to Use
|
|
47
|
+
|
|
48
|
+
Use `useTeardown` when a component is the **owner boundary** for singleton state — the point in the tree where the singleton's lifecycle should end.
|
|
49
|
+
|
|
50
|
+
Common scenarios:
|
|
51
|
+
|
|
52
|
+
- A page component that creates a singleton ViewModel (via `useSingleton`) and wants the singleton cleaned up when the user navigates away
|
|
53
|
+
- A layout shell that owns shared infrastructure singletons (EventBus, Channel) and should dispose them when the shell unmounts
|
|
54
|
+
- A route boundary that needs to reset shared Collections when the feature area is exited
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
function ChatPage() {
|
|
58
|
+
const [state, vm] = useSingleton(ChatViewModel, { messages: [] });
|
|
59
|
+
useTeardown(ChatViewModel, ChatChannel);
|
|
60
|
+
|
|
61
|
+
return <div>{/* ... */}</div>;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When the user navigates away from `ChatPage`, both `ChatViewModel` and `ChatChannel` singletons are disposed and removed from the registry. The next time `ChatPage` mounts, `singleton(ChatViewModel)` creates a fresh instance.
|
|
66
|
+
|
|
67
|
+
### When NOT to Use
|
|
68
|
+
|
|
69
|
+
| Scenario | Why |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Component-scoped ViewModels via `useLocal` | `useLocal` handles dispose automatically — no singleton involved |
|
|
72
|
+
| Singletons that should outlive the component | If the singleton is shared app-wide (auth, theme), don't tear it down on page navigation |
|
|
73
|
+
| Test cleanup | Use `teardownAll()` in `beforeEach` instead — see [Singleton: Test Cleanup](../singleton.md) |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Single Singleton
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
function DashboardPage() {
|
|
83
|
+
const [state, vm] = useSingleton(DashboardViewModel, { data: null });
|
|
84
|
+
useTeardown(DashboardViewModel);
|
|
85
|
+
|
|
86
|
+
return <div>{/* ... */}</div>;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Multiple Singletons
|
|
91
|
+
|
|
92
|
+
Pass all classes in a single call:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
function ChatPage() {
|
|
96
|
+
const [state, vm] = useSingleton(ChatViewModel, { messages: [] });
|
|
97
|
+
useTeardown(ChatViewModel, ChatChannel, ChatCollection);
|
|
98
|
+
|
|
99
|
+
return <div>{/* ... */}</div>;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Each class is torn down independently — if one was already disposed, the others are still cleaned up.
|
|
104
|
+
|
|
105
|
+
### Non-Existent Singletons
|
|
106
|
+
|
|
107
|
+
If a class was never registered via `singleton()`, `useTeardown` is a no-op for that class. No error thrown:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
function Component() {
|
|
111
|
+
// CounterVM was never created as a singleton — safe, does nothing
|
|
112
|
+
useTeardown(CounterVM);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## StrictMode Safety
|
|
120
|
+
|
|
121
|
+
React StrictMode double-mounts components in development to expose impure effects. `useTeardown` handles this correctly:
|
|
122
|
+
|
|
123
|
+
1. **First mount** — `mountedRef` set to `true`
|
|
124
|
+
2. **StrictMode unmount** — cleanup runs, `mountedRef` set to `false`, `setTimeout` scheduled
|
|
125
|
+
3. **StrictMode remount** — `mountedRef` set back to `true` before the timeout fires
|
|
126
|
+
4. **Timeout fires** — checks `mountedRef`, sees `true`, skips teardown
|
|
127
|
+
|
|
128
|
+
The singleton survives the fake unmount. On a real unmount (no remount follows), the timeout fires with `mountedRef` still `false` and teardown proceeds.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Best Practices
|
|
133
|
+
|
|
134
|
+
**Place `useTeardown` in the same component that uses `useSingleton`.** The owner of the singleton subscription should be the owner of its teardown. This keeps lifecycle management colocated:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
// Good: colocated
|
|
138
|
+
function ChatPage() {
|
|
139
|
+
const [state, vm] = useSingleton(ChatViewModel, { messages: [] });
|
|
140
|
+
useTeardown(ChatViewModel);
|
|
141
|
+
return <div>{/* ... */}</div>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Bad: teardown in a different component than the subscriber
|
|
145
|
+
function ChatPage() {
|
|
146
|
+
const [state, vm] = useSingleton(ChatViewModel, { messages: [] });
|
|
147
|
+
return <ChatContent />;
|
|
148
|
+
}
|
|
149
|
+
function AppShell() {
|
|
150
|
+
useTeardown(ChatViewModel); // far from where it's used
|
|
151
|
+
return <Outlet />;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Don't tear down singletons that other mounted components depend on.** If two sibling components both use `useSingleton(CartViewModel)`, tearing it down when one unmounts will dispose the instance the other is still reading. Only tear down at the highest common owner — or don't tear down at all if the singleton should be app-wide.
|
|
156
|
+
|
|
157
|
+
**Don't use `useTeardown` with `useLocal`.** `useLocal` creates and disposes its own instance — it doesn't use the singleton registry. `useTeardown` only affects singletons.
|
|
158
|
+
|
|
159
|
+
**Prefer `teardownAll()` in tests over `useTeardown`.** Test cleanup should use `teardownAll()` in `beforeEach` for complete isolation. `useTeardown` is a runtime hook for production component lifecycles.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Related
|
|
164
|
+
|
|
165
|
+
- [Singleton Registry](../singleton.md) — `singleton()`, `teardown()`, `teardownAll()`, `hasSingleton()`
|
|
166
|
+
- [ViewModel](../ViewModel.md) — most common class used with singleton teardown
|
|
167
|
+
- [Collection](../Collection.md) — shared data caches often torn down with their owning ViewModel
|
|
168
|
+
- [Channel](../Channel.md) — persistent connections that should be disposed when no longer needed
|
|
169
|
+
- [EventBus](../EventBus.md) — app-level event buses that may need teardown on route exit
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render } from '@testing-library/react';
|
|
5
|
+
import { vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { ViewModel } from '../ViewModel';
|
|
7
|
+
import { singleton, hasSingleton, teardownAll } from '../singleton';
|
|
8
|
+
import { useTeardown } from './use-teardown';
|
|
9
|
+
|
|
10
|
+
interface CountState {
|
|
11
|
+
count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class CounterVM extends ViewModel<CountState> {
|
|
15
|
+
constructor() {
|
|
16
|
+
super({ count: 0 });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class AnotherVM extends ViewModel<CountState> {
|
|
21
|
+
constructor() {
|
|
22
|
+
super({ count: 0 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('useTeardown', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.runAllTimers();
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
teardownAll();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should teardown singleton on unmount', () => {
|
|
38
|
+
singleton(CounterVM);
|
|
39
|
+
expect(hasSingleton(CounterVM)).toBe(true);
|
|
40
|
+
|
|
41
|
+
function Component() {
|
|
42
|
+
useTeardown(CounterVM);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { unmount } = render(<Component />);
|
|
47
|
+
expect(hasSingleton(CounterVM)).toBe(true);
|
|
48
|
+
|
|
49
|
+
unmount();
|
|
50
|
+
// Teardown is deferred
|
|
51
|
+
expect(hasSingleton(CounterVM)).toBe(true);
|
|
52
|
+
|
|
53
|
+
vi.runAllTimers();
|
|
54
|
+
expect(hasSingleton(CounterVM)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should teardown multiple singletons', () => {
|
|
58
|
+
singleton(CounterVM);
|
|
59
|
+
singleton(AnotherVM);
|
|
60
|
+
expect(hasSingleton(CounterVM)).toBe(true);
|
|
61
|
+
expect(hasSingleton(AnotherVM)).toBe(true);
|
|
62
|
+
|
|
63
|
+
function Component() {
|
|
64
|
+
useTeardown(CounterVM, AnotherVM);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { unmount } = render(<Component />);
|
|
69
|
+
unmount();
|
|
70
|
+
|
|
71
|
+
vi.runAllTimers();
|
|
72
|
+
expect(hasSingleton(CounterVM)).toBe(false);
|
|
73
|
+
expect(hasSingleton(AnotherVM)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should not throw if singleton does not exist', () => {
|
|
77
|
+
function Component() {
|
|
78
|
+
useTeardown(CounterVM);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { unmount } = render(<Component />);
|
|
83
|
+
unmount();
|
|
84
|
+
expect(() => vi.runAllTimers()).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import type { Disposable } from '../types';
|
|
3
|
+
import { teardown } from '../singleton';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Teardown singleton class(es) on unmount.
|
|
7
|
+
* Uses deferred disposal to handle StrictMode's double-mount cycle.
|
|
8
|
+
*/
|
|
9
|
+
export function useTeardown(
|
|
10
|
+
...Classes: Array<new (...args: unknown[]) => Disposable>
|
|
11
|
+
): void {
|
|
12
|
+
const mountedRef = useRef(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
mountedRef.current = true;
|
|
16
|
+
return () => {
|
|
17
|
+
mountedRef.current = false;
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
if (!mountedRef.current) {
|
|
20
|
+
for (const Class of Classes) {
|
|
21
|
+
teardown(Class);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}, 0);
|
|
25
|
+
};
|
|
26
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
27
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { NativeCollection } from './NativeCollection';
|
|
3
|
+
import { teardownAll } from '../singleton';
|
|
4
|
+
|
|
5
|
+
interface Todo {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
done: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// In-memory async storage mock
|
|
12
|
+
function createMockStorage() {
|
|
13
|
+
const store = new Map<string, string>();
|
|
14
|
+
return {
|
|
15
|
+
store,
|
|
16
|
+
getItem: vi.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
|
17
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
18
|
+
store.set(key, value);
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}),
|
|
21
|
+
removeItem: vi.fn((key: string) => {
|
|
22
|
+
store.delete(key);
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class TodosCollection extends NativeCollection<Todo> {
|
|
29
|
+
protected readonly storageKey = 'todos';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tick = () => new Promise((r) => setTimeout(r, 10));
|
|
33
|
+
|
|
34
|
+
describe('NativeCollection', () => {
|
|
35
|
+
let mock: ReturnType<typeof createMockStorage>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
teardownAll();
|
|
39
|
+
NativeCollection.resetAdapter();
|
|
40
|
+
mock = createMockStorage();
|
|
41
|
+
NativeCollection.configure(mock);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
NativeCollection.resetAdapter();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('configure pattern', () => {
|
|
49
|
+
it('uses configured adapter for storage', async () => {
|
|
50
|
+
const c = new TodosCollection();
|
|
51
|
+
c.add({ id: '1', title: 'Test', done: false });
|
|
52
|
+
await tick();
|
|
53
|
+
|
|
54
|
+
expect(mock.setItem).toHaveBeenCalledWith('todos', expect.any(String));
|
|
55
|
+
const stored = JSON.parse(mock.store.get('todos')!);
|
|
56
|
+
expect(stored).toEqual([{ id: '1', title: 'Test', done: false }]);
|
|
57
|
+
c.dispose();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('hydrates from configured adapter', async () => {
|
|
61
|
+
mock.store.set(
|
|
62
|
+
'todos',
|
|
63
|
+
JSON.stringify([
|
|
64
|
+
{ id: '1', title: 'Existing', done: true },
|
|
65
|
+
{ id: '2', title: 'Another', done: false },
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const c = new TodosCollection();
|
|
70
|
+
await c.hydrate();
|
|
71
|
+
|
|
72
|
+
expect(c.hydrated).toBe(true);
|
|
73
|
+
expect(c.length).toBe(2);
|
|
74
|
+
expect(c.get('1')!.title).toBe('Existing');
|
|
75
|
+
expect(c.get('2')!.done).toBe(false);
|
|
76
|
+
c.dispose();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('hydrate is idempotent', async () => {
|
|
80
|
+
mock.store.set('todos', JSON.stringify([{ id: '1', title: 'A', done: false }]));
|
|
81
|
+
|
|
82
|
+
const c = new TodosCollection();
|
|
83
|
+
await c.hydrate();
|
|
84
|
+
c.add({ id: '2', title: 'B', done: false });
|
|
85
|
+
const items = await c.hydrate();
|
|
86
|
+
|
|
87
|
+
expect(items).toHaveLength(2);
|
|
88
|
+
expect(mock.getItem).toHaveBeenCalledTimes(1);
|
|
89
|
+
c.dispose();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('per-class override', () => {
|
|
94
|
+
it('per-class methods take priority over configure()', async () => {
|
|
95
|
+
const customMock = createMockStorage();
|
|
96
|
+
|
|
97
|
+
class CustomCollection extends NativeCollection<Todo> {
|
|
98
|
+
protected readonly storageKey = 'custom';
|
|
99
|
+
protected override getItem(key: string) {
|
|
100
|
+
return customMock.getItem(key);
|
|
101
|
+
}
|
|
102
|
+
protected override setItem(key: string, value: string) {
|
|
103
|
+
return customMock.setItem(key, value);
|
|
104
|
+
}
|
|
105
|
+
protected override removeItem(key: string) {
|
|
106
|
+
return customMock.removeItem(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const c = new CustomCollection();
|
|
111
|
+
c.add({ id: '1', title: 'Custom', done: false });
|
|
112
|
+
await tick();
|
|
113
|
+
|
|
114
|
+
// Custom mock was used, not the global mock
|
|
115
|
+
expect(customMock.setItem).toHaveBeenCalled();
|
|
116
|
+
expect(mock.setItem).not.toHaveBeenCalled();
|
|
117
|
+
|
|
118
|
+
const stored = JSON.parse(customMock.store.get('custom')!);
|
|
119
|
+
expect(stored).toEqual([{ id: '1', title: 'Custom', done: false }]);
|
|
120
|
+
c.dispose();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('no adapter configured', () => {
|
|
125
|
+
it('errors when no adapter configured and no override', async () => {
|
|
126
|
+
NativeCollection.resetAdapter();
|
|
127
|
+
const errorSpy = vi.fn();
|
|
128
|
+
|
|
129
|
+
class UnconfiguredCollection extends NativeCollection<Todo> {
|
|
130
|
+
protected readonly storageKey = 'unconfigured';
|
|
131
|
+
protected onPersistError = errorSpy;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const c = new UnconfiguredCollection();
|
|
135
|
+
await c.hydrate();
|
|
136
|
+
|
|
137
|
+
// Error routed to onPersistError hook
|
|
138
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
139
|
+
expect.objectContaining({ message: expect.stringContaining('No storage adapter configured') }),
|
|
140
|
+
);
|
|
141
|
+
c.dispose();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('persistence (blob strategy)', () => {
|
|
146
|
+
it('mutations persist via setItem', async () => {
|
|
147
|
+
const c = new TodosCollection();
|
|
148
|
+
c.add({ id: '1', title: 'A', done: false });
|
|
149
|
+
await tick();
|
|
150
|
+
|
|
151
|
+
c.add({ id: '2', title: 'B', done: true });
|
|
152
|
+
await tick();
|
|
153
|
+
|
|
154
|
+
const stored = JSON.parse(mock.store.get('todos')!);
|
|
155
|
+
expect(stored).toHaveLength(2);
|
|
156
|
+
c.dispose();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('remove persists correctly', async () => {
|
|
160
|
+
const c = new TodosCollection();
|
|
161
|
+
c.add(
|
|
162
|
+
{ id: '1', title: 'A', done: false },
|
|
163
|
+
{ id: '2', title: 'B', done: true },
|
|
164
|
+
);
|
|
165
|
+
await tick();
|
|
166
|
+
|
|
167
|
+
c.remove('1');
|
|
168
|
+
await tick();
|
|
169
|
+
|
|
170
|
+
const stored = JSON.parse(mock.store.get('todos')!);
|
|
171
|
+
expect(stored).toEqual([{ id: '2', title: 'B', done: true }]);
|
|
172
|
+
c.dispose();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('clearStorage', () => {
|
|
177
|
+
it('calls removeItem and clears in-memory', async () => {
|
|
178
|
+
const c = new TodosCollection();
|
|
179
|
+
c.add({ id: '1', title: 'Test', done: false });
|
|
180
|
+
await tick();
|
|
181
|
+
|
|
182
|
+
await c.clearStorage();
|
|
183
|
+
|
|
184
|
+
expect(c.length).toBe(0);
|
|
185
|
+
expect(mock.removeItem).toHaveBeenCalledWith('todos');
|
|
186
|
+
c.dispose();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('JSON round-trip', () => {
|
|
191
|
+
it('preserves data through serialize/deserialize cycle', async () => {
|
|
192
|
+
const c = new TodosCollection();
|
|
193
|
+
c.add({ id: '1', title: 'Round-trip', done: true });
|
|
194
|
+
await tick();
|
|
195
|
+
c.dispose();
|
|
196
|
+
|
|
197
|
+
const c2 = new TodosCollection();
|
|
198
|
+
await c2.hydrate();
|
|
199
|
+
expect(c2.get('1')).toEqual({ id: '1', title: 'Round-trip', done: true });
|
|
200
|
+
c2.dispose();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('custom serialize/deserialize', () => {
|
|
205
|
+
it('uses overridden serialize/deserialize', async () => {
|
|
206
|
+
class CustomSerializeCollection extends NativeCollection<Todo> {
|
|
207
|
+
protected readonly storageKey = 'custom-ser';
|
|
208
|
+
protected override serialize(items: Todo[]): string {
|
|
209
|
+
return JSON.stringify({ version: 1, data: items });
|
|
210
|
+
}
|
|
211
|
+
protected override deserialize(raw: string): Todo[] {
|
|
212
|
+
const parsed = JSON.parse(raw);
|
|
213
|
+
return parsed.data;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const c = new CustomSerializeCollection();
|
|
218
|
+
c.add({ id: '1', title: 'Custom', done: false });
|
|
219
|
+
await tick();
|
|
220
|
+
|
|
221
|
+
const raw = JSON.parse(mock.store.get('custom-ser')!);
|
|
222
|
+
expect(raw.version).toBe(1);
|
|
223
|
+
expect(raw.data).toEqual([{ id: '1', title: 'Custom', done: false }]);
|
|
224
|
+
c.dispose();
|
|
225
|
+
|
|
226
|
+
// Verify it can read back
|
|
227
|
+
const c2 = new CustomSerializeCollection();
|
|
228
|
+
await c2.hydrate();
|
|
229
|
+
expect(c2.get('1')!.title).toBe('Custom');
|
|
230
|
+
c2.dispose();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('corrupted data', () => {
|
|
235
|
+
it('handles corrupted JSON gracefully', async () => {
|
|
236
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
237
|
+
mock.store.set('todos', 'not-valid-json{{{');
|
|
238
|
+
|
|
239
|
+
const c = new TodosCollection();
|
|
240
|
+
await c.hydrate();
|
|
241
|
+
|
|
242
|
+
expect(c.length).toBe(0);
|
|
243
|
+
expect(c.hydrated).toBe(true);
|
|
244
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Corrupted data'));
|
|
245
|
+
|
|
246
|
+
warnSpy.mockRestore();
|
|
247
|
+
c.dispose();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|