mvc-kit 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +31 -0
  2. package/agent-config/claude-code/skills/guide/anti-patterns.md +67 -3
  3. package/agent-config/claude-code/skills/guide/api-reference.md +170 -2
  4. package/agent-config/claude-code/skills/guide/patterns.md +158 -0
  5. package/agent-config/copilot/copilot-instructions.md +56 -0
  6. package/agent-config/cursor/cursorrules +56 -0
  7. package/dist/Channel.cjs +5 -0
  8. package/dist/Channel.cjs.map +1 -1
  9. package/dist/Channel.d.ts +1 -0
  10. package/dist/Channel.d.ts.map +1 -1
  11. package/dist/Channel.js +5 -0
  12. package/dist/Channel.js.map +1 -1
  13. package/dist/Collection.cjs +55 -14
  14. package/dist/Collection.cjs.map +1 -1
  15. package/dist/Collection.d.ts +2 -2
  16. package/dist/Collection.d.ts.map +1 -1
  17. package/dist/Collection.js +55 -14
  18. package/dist/Collection.js.map +1 -1
  19. package/dist/Controller.cjs +5 -0
  20. package/dist/Controller.cjs.map +1 -1
  21. package/dist/Controller.d.ts +1 -0
  22. package/dist/Controller.d.ts.map +1 -1
  23. package/dist/Controller.js +5 -0
  24. package/dist/Controller.js.map +1 -1
  25. package/dist/EventBus.cjs +5 -0
  26. package/dist/EventBus.cjs.map +1 -1
  27. package/dist/EventBus.d.ts +1 -0
  28. package/dist/EventBus.d.ts.map +1 -1
  29. package/dist/EventBus.js +5 -0
  30. package/dist/EventBus.js.map +1 -1
  31. package/dist/Feed.cjs +90 -0
  32. package/dist/Feed.cjs.map +1 -0
  33. package/dist/Feed.d.ts +47 -0
  34. package/dist/Feed.d.ts.map +1 -0
  35. package/dist/Feed.js +90 -0
  36. package/dist/Feed.js.map +1 -0
  37. package/dist/Model.cjs +6 -3
  38. package/dist/Model.cjs.map +1 -1
  39. package/dist/Model.d.ts +1 -1
  40. package/dist/Model.d.ts.map +1 -1
  41. package/dist/Model.js +6 -3
  42. package/dist/Model.js.map +1 -1
  43. package/dist/Pagination.cjs +86 -0
  44. package/dist/Pagination.cjs.map +1 -0
  45. package/dist/Pagination.d.ts +39 -0
  46. package/dist/Pagination.d.ts.map +1 -0
  47. package/dist/Pagination.js +86 -0
  48. package/dist/Pagination.js.map +1 -0
  49. package/dist/Pending.cjs +301 -0
  50. package/dist/Pending.cjs.map +1 -0
  51. package/dist/Pending.d.ts +91 -0
  52. package/dist/Pending.d.ts.map +1 -0
  53. package/dist/Pending.js +301 -0
  54. package/dist/Pending.js.map +1 -0
  55. package/dist/PersistentCollection.cjs +9 -6
  56. package/dist/PersistentCollection.cjs.map +1 -1
  57. package/dist/PersistentCollection.d.ts +6 -1
  58. package/dist/PersistentCollection.d.ts.map +1 -1
  59. package/dist/PersistentCollection.js +9 -6
  60. package/dist/PersistentCollection.js.map +1 -1
  61. package/dist/Resource.cjs +3 -0
  62. package/dist/Resource.cjs.map +1 -1
  63. package/dist/Resource.d.ts +3 -0
  64. package/dist/Resource.d.ts.map +1 -1
  65. package/dist/Resource.js +3 -0
  66. package/dist/Resource.js.map +1 -1
  67. package/dist/Selection.cjs +103 -0
  68. package/dist/Selection.cjs.map +1 -0
  69. package/dist/Selection.d.ts +37 -0
  70. package/dist/Selection.d.ts.map +1 -0
  71. package/dist/Selection.js +103 -0
  72. package/dist/Selection.js.map +1 -0
  73. package/dist/Service.cjs +5 -0
  74. package/dist/Service.cjs.map +1 -1
  75. package/dist/Service.d.ts +1 -0
  76. package/dist/Service.d.ts.map +1 -1
  77. package/dist/Service.js +5 -0
  78. package/dist/Service.js.map +1 -1
  79. package/dist/Sorting.cjs +116 -0
  80. package/dist/Sorting.cjs.map +1 -0
  81. package/dist/Sorting.d.ts +43 -0
  82. package/dist/Sorting.d.ts.map +1 -0
  83. package/dist/Sorting.js +116 -0
  84. package/dist/Sorting.js.map +1 -0
  85. package/dist/ViewModel.cjs +45 -17
  86. package/dist/ViewModel.cjs.map +1 -1
  87. package/dist/ViewModel.d.ts +13 -4
  88. package/dist/ViewModel.d.ts.map +1 -1
  89. package/dist/ViewModel.js +45 -17
  90. package/dist/ViewModel.js.map +1 -1
  91. package/dist/bindPublicMethods.cjs +27 -0
  92. package/dist/bindPublicMethods.cjs.map +1 -0
  93. package/dist/bindPublicMethods.d.ts +18 -0
  94. package/dist/bindPublicMethods.d.ts.map +1 -0
  95. package/dist/bindPublicMethods.js +27 -0
  96. package/dist/bindPublicMethods.js.map +1 -0
  97. package/dist/index.d.ts +8 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/mvc-kit.cjs +10 -0
  100. package/dist/mvc-kit.cjs.map +1 -1
  101. package/dist/mvc-kit.js +10 -0
  102. package/dist/mvc-kit.js.map +1 -1
  103. package/dist/react/components/CardList.cjs +42 -0
  104. package/dist/react/components/CardList.cjs.map +1 -0
  105. package/dist/react/components/CardList.d.ts +22 -0
  106. package/dist/react/components/CardList.d.ts.map +1 -0
  107. package/dist/react/components/CardList.js +42 -0
  108. package/dist/react/components/CardList.js.map +1 -0
  109. package/dist/react/components/DataTable.cjs +179 -0
  110. package/dist/react/components/DataTable.cjs.map +1 -0
  111. package/dist/react/components/DataTable.d.ts +30 -0
  112. package/dist/react/components/DataTable.d.ts.map +1 -0
  113. package/dist/react/components/DataTable.js +179 -0
  114. package/dist/react/components/DataTable.js.map +1 -0
  115. package/dist/react/components/InfiniteScroll.cjs +44 -0
  116. package/dist/react/components/InfiniteScroll.cjs.map +1 -0
  117. package/dist/react/components/InfiniteScroll.d.ts +21 -0
  118. package/dist/react/components/InfiniteScroll.d.ts.map +1 -0
  119. package/dist/react/components/InfiniteScroll.js +44 -0
  120. package/dist/react/components/InfiniteScroll.js.map +1 -0
  121. package/dist/react/components/types.cjs +15 -0
  122. package/dist/react/components/types.cjs.map +1 -0
  123. package/dist/react/components/types.d.ts +71 -0
  124. package/dist/react/components/types.d.ts.map +1 -0
  125. package/dist/react/components/types.js +15 -0
  126. package/dist/react/components/types.js.map +1 -0
  127. package/dist/react/index.d.ts +7 -0
  128. package/dist/react/index.d.ts.map +1 -1
  129. package/dist/react-native/NativeCollection.cjs +3 -0
  130. package/dist/react-native/NativeCollection.cjs.map +1 -1
  131. package/dist/react-native/NativeCollection.d.ts +3 -0
  132. package/dist/react-native/NativeCollection.d.ts.map +1 -1
  133. package/dist/react-native/NativeCollection.js +3 -0
  134. package/dist/react-native/NativeCollection.js.map +1 -1
  135. package/dist/react.cjs +6 -0
  136. package/dist/react.cjs.map +1 -1
  137. package/dist/react.js +6 -0
  138. package/dist/react.js.map +1 -1
  139. package/dist/walkPrototypeChain.cjs.map +1 -1
  140. package/dist/walkPrototypeChain.d.ts +1 -1
  141. package/dist/walkPrototypeChain.js.map +1 -1
  142. package/dist/web/idb.cjs.map +1 -1
  143. package/dist/web/idb.d.ts +18 -0
  144. package/dist/web/idb.d.ts.map +1 -1
  145. package/dist/web/idb.js.map +1 -1
  146. package/dist/wrapAsyncMethods.cjs +36 -44
  147. package/dist/wrapAsyncMethods.cjs.map +1 -1
  148. package/dist/wrapAsyncMethods.d.ts +2 -0
  149. package/dist/wrapAsyncMethods.d.ts.map +1 -1
  150. package/dist/wrapAsyncMethods.js +36 -44
  151. package/dist/wrapAsyncMethods.js.map +1 -1
  152. package/package.json +2 -1
package/README.md CHANGED
@@ -782,6 +782,16 @@ function App() {
782
782
  | `Service` | Non-reactive infrastructure service (Disposable) |
783
783
  | `EventBus<E>` | Typed pub/sub event bus |
784
784
 
785
+ ### Composable Helpers
786
+
787
+ | Class | Description |
788
+ |-------|-------------|
789
+ | `Sorting<T>` | Multi-column sort state with 3-click toggle cycle and `apply()` pipeline |
790
+ | `Pagination` | Page/pageSize state with `apply()` slicing |
791
+ | `Selection<K>` | Key-based selection set with toggle/select-all semantics |
792
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server-side pagination |
793
+ | `Pending<K, Meta?>` | Per-item operation queue with retry + status tracking + optional typed metadata |
794
+
785
795
  ### Interfaces
786
796
 
787
797
  | Interface | Description |
@@ -829,6 +839,14 @@ function App() {
829
839
  | `useResolve(Class, ...args)` | Resolve from Provider or singleton |
830
840
  | `useTeardown(...Classes)` | Teardown singletons on unmount |
831
841
 
842
+ ### Headless React Components
843
+
844
+ | Component | Description |
845
+ |-----------|-------------|
846
+ | `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helper instances directly |
847
+ | `CardList<T>` | Unstyled list/grid with render-prop items |
848
+ | `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
849
+
832
850
  ## Behavior Notes
833
851
 
834
852
  - State is always shallow-frozen with `Object.freeze()`
@@ -873,6 +891,11 @@ Each core class and React hook has a dedicated reference doc with full API detai
873
891
  | [EventBus](src/EventBus.md) | Typed pub/sub for cross-cutting event communication |
874
892
  | [Channel](src/Channel.md) | Persistent connections (WebSocket, SSE) with auto-reconnect |
875
893
  | [Singleton Registry](src/singleton.md) | Global instance management: `singleton()`, `teardown()`, `teardownAll()` |
894
+ | [Sorting](src/Sorting.md) | Multi-column sort state with 3-click toggle cycle and apply pipeline |
895
+ | [Pagination](src/Pagination.md) | Page/pageSize state with array slicing |
896
+ | [Selection](src/Selection.md) | Key-based selection set with toggle/select-all |
897
+ | [Feed](src/Feed.md) | Cursor + hasMore + item accumulation for server-side pagination |
898
+ | [Pending](src/Pending.md) | Per-item operation queue with retry + status tracking |
876
899
 
877
900
  **React Hooks**
878
901
 
@@ -885,6 +908,14 @@ Each core class and React hook has a dedicated reference doc with full API detai
885
908
  | [useEvent & useEmit](src/react/use-event-bus.md) | Subscribe to and emit typed events from EventBus or ViewModel |
886
909
  | [useTeardown](src/react/use-teardown.md) | Dispose singleton instances on component unmount |
887
910
 
911
+ **Headless Components**
912
+
913
+ | Doc | Description |
914
+ |-----|-------------|
915
+ | [DataTable](src/react/components/DataTable.md) | Unstyled table with sort, selection, pagination; accepts helpers directly |
916
+ | [CardList](src/react/components/CardList.md) | Unstyled list/grid with render-prop items |
917
+ | [InfiniteScroll](src/react/components/InfiniteScroll.md) | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
918
+
888
919
  ## Dev Mode (`__MVC_KIT_DEV__`)
889
920
 
890
921
  mvc-kit includes development-only safety checks guarded by the `__MVC_KIT_DEV__` flag. When enabled, these checks catch common mistakes at development time with clear `console.error` messages instead of silent infinite loops or hard-to-debug failures.
@@ -433,7 +433,7 @@ async load() {
433
433
 
434
434
  ---
435
435
 
436
- ## 18. Persisting Ephemeral UI State
436
+ ## 19. Persisting Ephemeral UI State
437
437
 
438
438
  ```typescript
439
439
  // BAD — persisting search results, selections, or high-churn data
@@ -451,7 +451,7 @@ Persistence adds I/O overhead on every mutation. For high-churn data, this cause
451
451
 
452
452
  ---
453
453
 
454
- ## 19. Using addCleanup for channel.on()/bus.on() Subscriptions
454
+ ## 20. Using addCleanup for channel.on()/bus.on() Subscriptions
455
455
 
456
456
  ```typescript
457
457
  // BAD — manual addCleanup is easy to forget and not reset-safe
@@ -468,7 +468,7 @@ protected onInit() {
468
468
 
469
469
  ---
470
470
 
471
- ## 20. Missing hydrate() for Async Adapters
471
+ ## 21. Missing hydrate() for Async Adapters
472
472
 
473
473
  ```typescript
474
474
  // BAD — accessing items before hydrate() on async adapter
@@ -486,3 +486,67 @@ class VM extends ViewModel {
486
486
  }
487
487
  }
488
488
  ```
489
+
490
+ ---
491
+
492
+ ## 22. Pending as a ViewModel Property
493
+
494
+ ```typescript
495
+ // BAD — Pending dies on component unmount, losing in-flight retries
496
+ class ItemsVM extends ViewModel {
497
+ readonly pending = new Pending<string>();
498
+ // ...
499
+ }
500
+
501
+ // GOOD — Pending lives on the singleton Resource
502
+ class ItemsResource extends Resource<Item> {
503
+ readonly pending = new Pending<string>();
504
+ }
505
+ class ItemsVM extends ViewModel {
506
+ private resource = singleton(ItemsResource);
507
+ get pending() { return this.resource.pending; }
508
+ }
509
+ ```
510
+
511
+ ---
512
+
513
+ ## 23. Passing disposeSignal to Pending's Execute Callback
514
+
515
+ ```typescript
516
+ // BAD — aborts operation when component unmounts, defeating resilience
517
+ this.pending.enqueue(id, 'delete', async () => {
518
+ await api.deleteItem(id, this.disposeSignal); // component unmount aborts!
519
+ });
520
+
521
+ // GOOD — use the signal from Pending (aborted only on cancel/supersede/dispose)
522
+ this.pending.enqueue(id, 'delete', async (signal) => {
523
+ await api.deleteItem(id, signal);
524
+ });
525
+ ```
526
+
527
+ ---
528
+
529
+ ## 24. Using collection.optimistic() Rollback with Pending
530
+
531
+ ```typescript
532
+ // BAD — snapshot goes stale during retries
533
+ async deleteItem(id: string) {
534
+ const rollback = this.collection.optimistic(() => this.remove(id));
535
+ this.pending.enqueue(id, 'delete', async (signal) => {
536
+ try {
537
+ await api.deleteItem(id, signal);
538
+ } catch (e) {
539
+ rollback(); // stale — other mutations may have happened during retries
540
+ throw e;
541
+ }
542
+ });
543
+ }
544
+
545
+ // GOOD — optimistic remove without rollback; let Pending manage failure UX
546
+ async deleteItem(id: string) {
547
+ this.optimistic(() => this.remove(id));
548
+ this.pending.enqueue(id, 'delete', async (signal) => {
549
+ await api.deleteItem(id, signal);
550
+ });
551
+ }
552
+ ```
@@ -5,12 +5,14 @@
5
5
  ```typescript
6
6
  // Core classes and utilities
7
7
  import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
8
+ import { Sorting, Pagination, Selection, Feed, Pending } from 'mvc-kit';
8
9
  import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
9
10
  import { HttpError, isAbortError, classifyError } from 'mvc-kit';
10
11
  import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ResourceAsyncMethodKeys, ChannelStatus } from 'mvc-kit';
11
12
 
12
- // React hooks
13
+ // React hooks and headless components
13
14
  import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
15
+ import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
14
16
  import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
15
17
 
16
18
  // Web storage adapters
@@ -157,7 +159,7 @@ Static overrides for auto-eviction (zero-cost when not configured):
157
159
  Abstract base for Collections that persist to external storage. Extends Collection with delta tracking, debounced writes, and hydration.
158
160
 
159
161
  ### Static Config
160
- - `static WRITE_DELAY = 100` — Debounce ms for storage writes. `0` = immediate.
162
+ - `static WRITE_DELAY = 0` — Debounce ms for storage writes. `0` = immediate (default). Override to a positive value to coalesce rapid mutations.
161
163
 
162
164
  ### Public API
163
165
  - `hydrate(): Promise<T[]>` — Load from storage, idempotent. Returns items.
@@ -283,6 +285,172 @@ No state, no getters, no async tracking.
283
285
 
284
286
  ---
285
287
 
288
+ ## Composable Helpers
289
+
290
+ Plain classes with `subscribe()` — auto-tracked when declared as ViewModel instance properties. Each has an `apply()` method that transforms arrays. Helpers manage state; ViewModels compose them in getters.
291
+
292
+ ### Sorting\<T\>
293
+
294
+ Multi-column sort state with 3-click toggle cycle (asc → desc → none).
295
+
296
+ ```typescript
297
+ new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] })
298
+ ```
299
+
300
+ - `sorts: readonly SortDescriptor[]` — Current sort descriptors in priority order. Each: `{ key: string, direction: 'asc' | 'desc' }`.
301
+ - `key: string | null` — Primary sort key (first descriptor), or null when empty.
302
+ - `direction: 'asc' | 'desc'` — Primary sort direction. Defaults to `'asc'` when empty.
303
+ - `isSorted(key: string): boolean` — Whether the given key is currently sorted.
304
+ - `directionOf(key: string): 'asc' | 'desc' | null` — Sort direction for a key, or null.
305
+ - `indexOf(key: string): number` — Priority index of a sorted key, or -1.
306
+ - `toggle(key: string): void` — 3-click cycle: not sorted → asc → desc → removed.
307
+ - `setSort(key: string, direction: 'asc' | 'desc'): void` — Replace all with a single sort.
308
+ - `setSorts(sorts: SortDescriptor[]): void` — Replace all sort descriptors.
309
+ - `reset(): void` — Clear all sorts.
310
+ - `apply(items: T[], compareFn?): T[]` — Returns sorted copy. Optional custom comparator `(a, b, key, dir) => number`.
311
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
312
+
313
+ ### Pagination
314
+
315
+ Page/pageSize state with `apply()` slicing.
316
+
317
+ ```typescript
318
+ new Pagination({ pageSize: 25 })
319
+ ```
320
+
321
+ - `page: number` — Current page (1-based).
322
+ - `pageSize: number` — Items per page.
323
+ - `setPage(page: number): void` — Navigate to a specific page (clamped to >= 1).
324
+ - `setPageSize(size: number): void` — Change page size, resets to page 1.
325
+ - `nextPage(): void` — Advance to the next page.
326
+ - `prevPage(): void` — Go back one page. No-op if already on page 1.
327
+ - `hasNext(total: number): boolean` — Whether there is a next page.
328
+ - `hasPrev(): boolean` — Whether there is a previous page.
329
+ - `pageCount(total: number): number` — Compute total pages.
330
+ - `reset(): void` — Reset to page 1.
331
+ - `apply<T>(items: T[]): T[]` — Slice array to the current page window.
332
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
333
+
334
+ ### Selection\<K\>
335
+
336
+ Key-based selection set with toggle/select-all semantics.
337
+
338
+ ```typescript
339
+ new Selection<string>()
340
+ ```
341
+
342
+ - `selected: ReadonlySet<K>` — Current selection.
343
+ - `count: number` — Number of selected items.
344
+ - `hasSelection: boolean` — Whether any items are selected.
345
+ - `isSelected(key: K): boolean` — Check if selected.
346
+ - `toggle(key: K): void` — Add or remove.
347
+ - `select(...keys: K[]): void` — Add to selection.
348
+ - `deselect(...keys: K[]): void` — Remove from selection.
349
+ - `toggleAll(allKeys: K[]): void` — If all selected, deselect all; otherwise select all.
350
+ - `set(...keys: K[]): void` — Replace entire selection atomically. Single notification. No-op if unchanged.
351
+ - `clear(): void` — Deselect all.
352
+ - `selectedFrom<T>(items, keyOf): T[]` — Filter items to those whose key is selected.
353
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
354
+
355
+ ### Feed\<T\>
356
+
357
+ Cursor + hasMore + item accumulation for server-side cursor-based pagination.
358
+
359
+ ```typescript
360
+ new Feed() // untyped (cursor/hasMore only)
361
+ new Feed<Message>() // typed (with item accumulation)
362
+ ```
363
+
364
+ - `cursor: string | null` — Current cursor for next page.
365
+ - `hasMore: boolean` — Whether more pages exist.
366
+ - `items: readonly T[]` — Accumulated items (from appendPage/prependPage).
367
+ - `count: number` — Number of accumulated items.
368
+ - `setResult(result): void` — Update cursor/hasMore only (does not affect items).
369
+ - `appendPage(page: FeedPage<T>): void` — Append page items and update cursor/hasMore.
370
+ - `prependPage(page: FeedPage<T>): void` — Prepend page items and update cursor/hasMore.
371
+ - `push(...items: T[]): void` — Add items without affecting cursor/hasMore.
372
+ - `filter(predicate: (item: T) => boolean): void` — Remove non-matching items. No-op if nothing filtered.
373
+ - `replacePage(page: FeedPage<T>): void` — Replace all items and update cursor/hasMore atomically. Ideal for pull-to-refresh.
374
+ - `reset(): void` — Clear cursor, items, set hasMore to true.
375
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
376
+
377
+ ### Pending\<K, Meta\>
378
+
379
+ Per-item operation queue with retry and status tracking. Lives on singleton Resource, not component-scoped ViewModel. Optional `Meta` generic for typed UI metadata on each operation.
380
+
381
+ ```typescript
382
+ new Pending<string>() // string keys, no metadata
383
+ new Pending<number>() // numeric keys
384
+ new Pending<string, { label: string }>() // with typed metadata
385
+ ```
386
+
387
+ - `getStatus(id: K): PendingOperation<Meta> | null` — Frozen snapshot of operation state (`active | retrying | failed`). Includes `meta: Meta | null`.
388
+ - `has(id: K): boolean` — Whether an operation exists for the given ID.
389
+ - `count: number` — Number of operations (all statuses).
390
+ - `hasPending: boolean` — Whether any operations are in-flight (active or retrying). Excludes failed.
391
+ - `hasFailed: boolean` — Whether any operations are in failed status.
392
+ - `failedCount: number` — Number of failed operations.
393
+ - `entries: readonly PendingEntry<K, Meta>[]` — All operations with ids. Cached, reference-stable between mutations. `PendingEntry` extends `PendingOperation<Meta>` with `readonly id: K`.
394
+ - `enqueue(id, operation, execute, meta?): void` — Fire-and-forget. Supersedes existing operation for same ID. Optional `meta` attached to snapshot.
395
+ - `retry(id: K): void` — Re-process a failed operation. Resets attempts to 0.
396
+ - `retryAll(): void` — Retry all failed operations.
397
+ - `cancel(id: K): void` — Abort signal, clear timer, remove operation.
398
+ - `cancelAll(): void` — Cancel all operations.
399
+ - `dismiss(id: K): void` — Remove a failed operation without retrying.
400
+ - `dismissAll(): void` — Remove all failed operations. Single notification.
401
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
402
+ - `dispose(): void` — Cancel all operations, clear listeners.
403
+
404
+ Static config (override via subclass): `MAX_RETRIES=5`, `RETRY_BASE=1000`, `RETRY_MAX=30000`, `RETRY_FACTOR=2`.
405
+
406
+ Overridable hooks: `isRetryable(error)`, `onConfirmed(id, operation)`, `onFailed(id, operation, error)`.
407
+
408
+ ---
409
+
410
+ ## Headless React Components
411
+
412
+ Unstyled render-prop components from `mvc-kit/react`. They render the output of ViewModel getters and helpers. Neither requires helpers — they work with any array. Helpers don't require components — they work with any rendering approach.
413
+
414
+ ### DataTable\<T\>
415
+
416
+ Unstyled table with sort headers, selection checkboxes, and pagination slots. Accepts helper instances directly via duck-typing.
417
+
418
+ ```tsx
419
+ <DataTable
420
+ items={vm.items}
421
+ columns={columns}
422
+ sort={vm.sorting}
423
+ selection={vm.selection}
424
+ pagination={vm.pagination}
425
+ paginationTotal={vm.filteredCount}
426
+ />
427
+ ```
428
+
429
+ ### CardList\<T\>
430
+
431
+ Unstyled list/grid with render-prop items.
432
+
433
+ ```tsx
434
+ <CardList items={vm.items} renderItem={(item) => <Card {...item} />} />
435
+ ```
436
+
437
+ ### InfiniteScroll
438
+
439
+ IntersectionObserver wrapper that calls `onLoadMore` when the sentinel enters the viewport. Supports `direction="up"` for chat-style reverse scroll.
440
+
441
+ ```tsx
442
+ <InfiniteScroll onLoadMore={() => vm.loadMore()} hasMore={vm.feed.hasMore} loading={vm.async.loadMore.loading}>
443
+ {vm.items.map(item => <Row key={item.id} {...item} />)}
444
+ </InfiniteScroll>
445
+
446
+ // Chat UI (reverse scroll)
447
+ <InfiniteScroll onLoadMore={() => vm.loadOlder()} hasMore={vm.feed.hasMore} direction="up">
448
+ {vm.messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
449
+ </InfiniteScroll>
450
+ ```
451
+
452
+ ---
453
+
286
454
  ## Singleton Registry
287
455
 
288
456
  ```typescript
@@ -65,6 +65,8 @@ setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
65
65
 
66
66
  The setter changes state → React re-renders → getters recompute. No manual refiltering needed.
67
67
 
68
+ All classes auto-bind methods, so they can be passed point-free as callbacks (`onFoo={vm.foo}`, `onClick={sorting.toggle}`, `onSubmit={model.commit}`).
69
+
68
70
  ---
69
71
 
70
72
  ## Encapsulating Collections
@@ -229,6 +231,162 @@ Thin subclass for singleton identity. No custom methods — query logic goes in
229
231
 
230
232
  ---
231
233
 
234
+ ## Composable Helpers Pattern
235
+
236
+ Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
237
+
238
+ ```typescript
239
+ interface FilterState {
240
+ search: string;
241
+ }
242
+
243
+ class UsersListVM extends ViewModel<FilterState> {
244
+ private users = singleton(UsersResource);
245
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
246
+ readonly pagination = new Pagination({ pageSize: 25 });
247
+ readonly selection = new Selection<string>();
248
+
249
+ // Getters compose helpers via apply()
250
+ get filtered(): User[] {
251
+ const { search } = this.state;
252
+ let result = this.users.items as User[];
253
+ if (search) {
254
+ const q = search.toLowerCase();
255
+ result = result.filter(u => u.name.toLowerCase().includes(q));
256
+ }
257
+ return result;
258
+ }
259
+
260
+ get items(): User[] {
261
+ return this.pagination.apply(this.sorting.apply(this.filtered));
262
+ }
263
+
264
+ get pageCount(): number {
265
+ return this.pagination.pageCount(this.filtered.length);
266
+ }
267
+
268
+ // --- Setters ---
269
+ setSearch(search: string) { this.set({ search }); }
270
+ }
271
+ ```
272
+
273
+ **Key points:**
274
+ - Helpers are plain classes, not ViewModels — no `init()` needed.
275
+ - `apply()` is a pure pipeline — chain them in getters: `pagination.apply(sorting.apply(filtered))`.
276
+ - `Selection` is key-based — works with any ID type.
277
+ - `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
278
+
279
+ ### Feed Pattern (cursor-based)
280
+
281
+ With Resource (shared data cache):
282
+
283
+ ```typescript
284
+ class FeedVM extends ViewModel {
285
+ private resource = singleton(PostsResource);
286
+ readonly feed = new Feed();
287
+
288
+ get items(): Post[] {
289
+ return this.resource.items as Post[];
290
+ }
291
+
292
+ async loadMore() {
293
+ const result = await this.api.getPosts(this.feed.cursor, this.disposeSignal);
294
+ this.resource.upsert(...result.items);
295
+ this.feed.setResult(result);
296
+ }
297
+
298
+ protected onInit() {
299
+ this.loadMore();
300
+ }
301
+ }
302
+ ```
303
+
304
+ With item accumulation (component-scoped):
305
+
306
+ ```typescript
307
+ class MessageThreadVM extends ViewModel<{ draft: string }> {
308
+ readonly feed = new Feed<Message>();
309
+
310
+ get messages() { return this.feed.items; }
311
+
312
+ async loadConversation(id: string) {
313
+ this.feed.reset();
314
+ const page = await this.service.getMessages(id, this.disposeSignal);
315
+ this.feed.appendPage(page);
316
+ }
317
+
318
+ async loadOlder() {
319
+ if (!this.feed.hasMore) return;
320
+ const page = await this.service.getMessages(id, this.disposeSignal, { cursor: this.feed.cursor });
321
+ this.feed.appendPage(page);
322
+ }
323
+ }
324
+ ```
325
+
326
+ ### Pending Pattern (resilient optimistic updates)
327
+
328
+ Pending lives on the singleton Resource so operations survive component unmount:
329
+
330
+ ```typescript
331
+ class ItemsResource extends Resource<Item> {
332
+ readonly pending = new Pending<string>();
333
+
334
+ async deleteItem(id: string) {
335
+ this.optimistic(() => this.remove(id));
336
+ this.pending.enqueue(id, 'delete', async (signal) => {
337
+ await api.deleteItem(id, signal);
338
+ });
339
+ }
340
+
341
+ protected override onDispose() {
342
+ this.pending.dispose();
343
+ }
344
+ }
345
+
346
+ class ItemsVM extends ViewModel<{ filter: string }> {
347
+ private resource = singleton(ItemsResource);
348
+
349
+ get pending() { return this.resource.pending; }
350
+ get items() { return this.resource.items; }
351
+
352
+ deleteItem(id: string) { this.resource.deleteItem(id); }
353
+ retryFailed() { this.resource.pending.retryAll(); }
354
+ }
355
+ ```
356
+
357
+ **Key points:**
358
+ - Pending uses the signal from `enqueue`'s callback — do not pass `vm.disposeSignal`.
359
+ - Retries automatically on network, timeout, and server errors. Fails immediately on 4xx.
360
+ - Override `isRetryable()` in a subclass to customize retry logic.
361
+
362
+ ### Headless Components
363
+
364
+ Pass helpers directly — DataTable duck-types their interfaces:
365
+
366
+ ```tsx
367
+ function UsersPage() {
368
+ const [state, vm] = useLocal(UsersListVM, { search: '' });
369
+
370
+ return (
371
+ <div>
372
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
373
+ <DataTable
374
+ items={vm.items}
375
+ columns={columns}
376
+ sort={vm.sorting}
377
+ selection={vm.selection}
378
+ pagination={vm.pagination}
379
+ paginationTotal={vm.filteredCount}
380
+ />
381
+ </div>
382
+ );
383
+ }
384
+ ```
385
+
386
+ Components and helpers are independent — use helpers without components, or components without helpers. InfiniteScroll supports `direction="up"` for chat-style reverse scroll.
387
+
388
+ ---
389
+
232
390
  ## Persistence Pattern
233
391
 
234
392
  ```typescript
@@ -15,13 +15,28 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
15
15
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
16
16
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
17
17
  | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
18
+ | `Sorting<T>` | Multi-column sort state with 3-click toggle cycle and `apply()` pipeline | ViewModel property |
19
+ | `Pagination` | Page/pageSize state with `apply()` slicing | ViewModel property |
20
+ | `Selection<K>` | Key-based selection set with toggle/select-all semantics | ViewModel property |
21
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server-side pagination | ViewModel property |
22
+ | `Pending<K, Meta?>` | Per-item operation queue with retry + status tracking + optional typed metadata | Resource property |
23
+
24
+ ## Headless React Components (`mvc-kit/react`)
25
+
26
+ | Component | Description |
27
+ |-----------|-------------|
28
+ | `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helpers directly |
29
+ | `CardList<T>` | Unstyled list/grid with render-prop items |
30
+ | `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
18
31
 
19
32
  ## Imports
20
33
 
21
34
  ```typescript
22
35
  import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
36
+ import { Sorting, Pagination, Selection, Feed, Pending } from 'mvc-kit';
23
37
  import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
24
38
  import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
39
+ import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
25
40
  import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
26
41
  import { NativeCollection } from 'mvc-kit/react-native';
27
42
  ```
@@ -83,6 +98,8 @@ class ItemsViewModel extends ViewModel<ItemState> {
83
98
 
84
99
  **Section order:** Private fields → Computed getters → Lifecycle → Actions → Setters.
85
100
 
101
+ All classes auto-bind methods — safe to pass point-free as callbacks (`onFoo={vm.foo}`, `onClick={sorting.toggle}`).
102
+
86
103
  ## Component Pattern
87
104
 
88
105
  ```tsx
@@ -160,6 +177,39 @@ class UserModel extends Model<UserFormState> {
160
177
  }
161
178
  ```
162
179
 
180
+ ## Composable Helpers Pattern
181
+
182
+ Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked -- getters that read them recompute when helper state changes.
183
+
184
+ ```typescript
185
+ class UsersListVM extends ViewModel<FilterState> {
186
+ private users = singleton(UsersResource);
187
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
188
+ readonly pagination = new Pagination({ pageSize: 25 });
189
+ readonly selection = new Selection<string>();
190
+
191
+ get filtered(): User[] {
192
+ const { search } = this.state;
193
+ let result = this.users.items as User[];
194
+ if (search) {
195
+ const q = search.toLowerCase();
196
+ result = result.filter(u => u.name.toLowerCase().includes(q));
197
+ }
198
+ return result;
199
+ }
200
+
201
+ get items(): User[] {
202
+ return this.pagination.apply(this.sorting.apply(this.filtered));
203
+ }
204
+ }
205
+ ```
206
+
207
+ **Key points:**
208
+ - Helpers are plain classes, not ViewModels -- no `init()` needed.
209
+ - `apply()` is a pure pipeline -- chain in getters: `pagination.apply(sorting.apply(filtered))`.
210
+ - `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
211
+ - Headless components (`DataTable`, `CardList`, `InfiniteScroll`) are optional -- helpers work with any rendering approach. DataTable accepts helpers directly via duck-typing (`sort={vm.sorting}`, `selection={vm.selection}`, `pagination={vm.pagination}`).
212
+
163
213
  ## Sharing Patterns
164
214
 
165
215
  1. **Pattern A** (default): Parent ViewModel passes props to presentational children
@@ -236,6 +286,8 @@ test('example', () => {
236
286
  - `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
237
287
  - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
238
288
  - Pass-through Service wrapping a typed API client → call the client directly from Resource
289
+ - `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
290
+ - Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
239
291
 
240
292
  ## Decision Framework
241
293
 
@@ -246,6 +298,10 @@ test('example', () => {
246
298
  - Cross-cutting events → **EventBus**
247
299
  - Persistent connection → **Channel**
248
300
  - Coordinates multiple ViewModels → **Controller** (rare)
301
+ - Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
302
+ - Cursor-based server pagination → **Feed** helper
303
+ - Per-item operation retry with status → **Pending** helper (on Resource)
304
+ - Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
249
305
 
250
306
  ## Dev Mode
251
307