mvc-kit 2.12.0 → 2.12.2

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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. 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
+ });