mvc-kit 2.8.0 → 2.9.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.
- package/README.md +29 -0
- package/agent-config/claude-code/skills/guide/anti-patterns.md +3 -3
- package/agent-config/claude-code/skills/guide/api-reference.md +138 -1
- package/agent-config/claude-code/skills/guide/patterns.md +120 -0
- package/agent-config/copilot/copilot-instructions.md +52 -0
- package/agent-config/cursor/cursorrules +52 -0
- package/dist/Collection.cjs +38 -0
- package/dist/Collection.cjs.map +1 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +38 -0
- package/dist/Collection.js.map +1 -1
- package/dist/Feed.cjs +86 -0
- package/dist/Feed.cjs.map +1 -0
- package/dist/Feed.d.ts +46 -0
- package/dist/Feed.d.ts.map +1 -0
- package/dist/Feed.js +86 -0
- package/dist/Feed.js.map +1 -0
- package/dist/Pagination.cjs +84 -0
- package/dist/Pagination.cjs.map +1 -0
- package/dist/Pagination.d.ts +39 -0
- package/dist/Pagination.d.ts.map +1 -0
- package/dist/Pagination.js +84 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/PersistentCollection.cjs +8 -5
- package/dist/PersistentCollection.cjs.map +1 -1
- package/dist/PersistentCollection.d.ts +6 -1
- package/dist/PersistentCollection.d.ts.map +1 -1
- package/dist/PersistentCollection.js +8 -5
- package/dist/PersistentCollection.js.map +1 -1
- package/dist/Resource.cjs +3 -0
- package/dist/Resource.cjs.map +1 -1
- package/dist/Resource.d.ts +3 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +3 -0
- package/dist/Resource.js.map +1 -1
- package/dist/Selection.cjs +99 -0
- package/dist/Selection.cjs.map +1 -0
- package/dist/Selection.d.ts +36 -0
- package/dist/Selection.d.ts.map +1 -0
- package/dist/Selection.js +99 -0
- package/dist/Selection.js.map +1 -0
- package/dist/Sorting.cjs +114 -0
- package/dist/Sorting.cjs.map +1 -0
- package/dist/Sorting.d.ts +43 -0
- package/dist/Sorting.d.ts.map +1 -0
- package/dist/Sorting.js +114 -0
- package/dist/Sorting.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +8 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +8 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/react/components/CardList.cjs +42 -0
- package/dist/react/components/CardList.cjs.map +1 -0
- package/dist/react/components/CardList.d.ts +22 -0
- package/dist/react/components/CardList.d.ts.map +1 -0
- package/dist/react/components/CardList.js +42 -0
- package/dist/react/components/CardList.js.map +1 -0
- package/dist/react/components/DataTable.cjs +179 -0
- package/dist/react/components/DataTable.cjs.map +1 -0
- package/dist/react/components/DataTable.d.ts +30 -0
- package/dist/react/components/DataTable.d.ts.map +1 -0
- package/dist/react/components/DataTable.js +179 -0
- package/dist/react/components/DataTable.js.map +1 -0
- package/dist/react/components/InfiniteScroll.cjs +44 -0
- package/dist/react/components/InfiniteScroll.cjs.map +1 -0
- package/dist/react/components/InfiniteScroll.d.ts +21 -0
- package/dist/react/components/InfiniteScroll.d.ts.map +1 -0
- package/dist/react/components/InfiniteScroll.js +44 -0
- package/dist/react/components/InfiniteScroll.js.map +1 -0
- package/dist/react/components/types.cjs +15 -0
- package/dist/react/components/types.cjs.map +1 -0
- package/dist/react/components/types.d.ts +71 -0
- package/dist/react/components/types.d.ts.map +1 -0
- package/dist/react/components/types.js +15 -0
- package/dist/react/components/types.js.map +1 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react-native/NativeCollection.cjs +3 -0
- package/dist/react-native/NativeCollection.cjs.map +1 -1
- package/dist/react-native/NativeCollection.d.ts +3 -0
- package/dist/react-native/NativeCollection.d.ts.map +1 -1
- package/dist/react-native/NativeCollection.js +3 -0
- package/dist/react-native/NativeCollection.js.map +1 -1
- package/dist/react.cjs +6 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +6 -0
- package/dist/react.js.map +1 -1
- package/dist/web/idb.cjs.map +1 -1
- package/dist/web/idb.d.ts +18 -0
- package/dist/web/idb.d.ts.map +1 -1
- package/dist/web/idb.js.map +1 -1
- package/dist/wrapAsyncMethods.cjs +21 -41
- package/dist/wrapAsyncMethods.cjs.map +1 -1
- package/dist/wrapAsyncMethods.d.ts +2 -0
- package/dist/wrapAsyncMethods.d.ts.map +1 -1
- package/dist/wrapAsyncMethods.js +21 -41
- package/dist/wrapAsyncMethods.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -782,6 +782,15 @@ 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
|
+
|
|
785
794
|
### Interfaces
|
|
786
795
|
|
|
787
796
|
| Interface | Description |
|
|
@@ -829,6 +838,14 @@ function App() {
|
|
|
829
838
|
| `useResolve(Class, ...args)` | Resolve from Provider or singleton |
|
|
830
839
|
| `useTeardown(...Classes)` | Teardown singletons on unmount |
|
|
831
840
|
|
|
841
|
+
### Headless React Components
|
|
842
|
+
|
|
843
|
+
| Component | Description |
|
|
844
|
+
|-----------|-------------|
|
|
845
|
+
| `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helper instances directly |
|
|
846
|
+
| `CardList<T>` | Unstyled list/grid with render-prop items |
|
|
847
|
+
| `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
|
|
848
|
+
|
|
832
849
|
## Behavior Notes
|
|
833
850
|
|
|
834
851
|
- State is always shallow-frozen with `Object.freeze()`
|
|
@@ -873,6 +890,10 @@ Each core class and React hook has a dedicated reference doc with full API detai
|
|
|
873
890
|
| [EventBus](src/EventBus.md) | Typed pub/sub for cross-cutting event communication |
|
|
874
891
|
| [Channel](src/Channel.md) | Persistent connections (WebSocket, SSE) with auto-reconnect |
|
|
875
892
|
| [Singleton Registry](src/singleton.md) | Global instance management: `singleton()`, `teardown()`, `teardownAll()` |
|
|
893
|
+
| [Sorting](src/Sorting.md) | Multi-column sort state with 3-click toggle cycle and apply pipeline |
|
|
894
|
+
| [Pagination](src/Pagination.md) | Page/pageSize state with array slicing |
|
|
895
|
+
| [Selection](src/Selection.md) | Key-based selection set with toggle/select-all |
|
|
896
|
+
| [Feed](src/Feed.md) | Cursor + hasMore + item accumulation for server-side pagination |
|
|
876
897
|
|
|
877
898
|
**React Hooks**
|
|
878
899
|
|
|
@@ -885,6 +906,14 @@ Each core class and React hook has a dedicated reference doc with full API detai
|
|
|
885
906
|
| [useEvent & useEmit](src/react/use-event-bus.md) | Subscribe to and emit typed events from EventBus or ViewModel |
|
|
886
907
|
| [useTeardown](src/react/use-teardown.md) | Dispose singleton instances on component unmount |
|
|
887
908
|
|
|
909
|
+
**Headless Components**
|
|
910
|
+
|
|
911
|
+
| Doc | Description |
|
|
912
|
+
|-----|-------------|
|
|
913
|
+
| [DataTable](src/react/components/DataTable.md) | Unstyled table with sort, selection, pagination; accepts helpers directly |
|
|
914
|
+
| [CardList](src/react/components/CardList.md) | Unstyled list/grid with render-prop items |
|
|
915
|
+
| [InfiniteScroll](src/react/components/InfiniteScroll.md) | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
|
|
916
|
+
|
|
888
917
|
## Dev Mode (`__MVC_KIT_DEV__`)
|
|
889
918
|
|
|
890
919
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
##
|
|
471
|
+
## 21. Missing hydrate() for Async Adapters
|
|
472
472
|
|
|
473
473
|
```typescript
|
|
474
474
|
// BAD — accessing items before hydrate() on async adapter
|
|
@@ -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 } 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
|
|
@@ -283,6 +285,141 @@ 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
|
+
---
|
|
378
|
+
|
|
379
|
+
## Headless React Components
|
|
380
|
+
|
|
381
|
+
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.
|
|
382
|
+
|
|
383
|
+
### DataTable\<T\>
|
|
384
|
+
|
|
385
|
+
Unstyled table with sort headers, selection checkboxes, and pagination slots. Accepts helper instances directly via duck-typing.
|
|
386
|
+
|
|
387
|
+
```tsx
|
|
388
|
+
<DataTable
|
|
389
|
+
items={vm.items}
|
|
390
|
+
columns={columns}
|
|
391
|
+
sort={vm.sorting}
|
|
392
|
+
selection={vm.selection}
|
|
393
|
+
pagination={vm.pagination}
|
|
394
|
+
paginationTotal={vm.filteredCount}
|
|
395
|
+
/>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### CardList\<T\>
|
|
399
|
+
|
|
400
|
+
Unstyled list/grid with render-prop items.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
<CardList items={vm.items} renderItem={(item) => <Card {...item} />} />
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### InfiniteScroll
|
|
407
|
+
|
|
408
|
+
IntersectionObserver wrapper that calls `onLoadMore` when the sentinel enters the viewport. Supports `direction="up"` for chat-style reverse scroll.
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
<InfiniteScroll onLoadMore={() => vm.loadMore()} hasMore={vm.feed.hasMore} loading={vm.async.loadMore.loading}>
|
|
412
|
+
{vm.items.map(item => <Row key={item.id} {...item} />)}
|
|
413
|
+
</InfiniteScroll>
|
|
414
|
+
|
|
415
|
+
// Chat UI (reverse scroll)
|
|
416
|
+
<InfiniteScroll onLoadMore={() => vm.loadOlder()} hasMore={vm.feed.hasMore} direction="up">
|
|
417
|
+
{vm.messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
|
|
418
|
+
</InfiniteScroll>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
286
423
|
## Singleton Registry
|
|
287
424
|
|
|
288
425
|
```typescript
|
|
@@ -229,6 +229,126 @@ Thin subclass for singleton identity. No custom methods — query logic goes in
|
|
|
229
229
|
|
|
230
230
|
---
|
|
231
231
|
|
|
232
|
+
## Composable Helpers Pattern
|
|
233
|
+
|
|
234
|
+
Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
interface FilterState {
|
|
238
|
+
search: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
class UsersListVM extends ViewModel<FilterState> {
|
|
242
|
+
private users = singleton(UsersResource);
|
|
243
|
+
readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
|
|
244
|
+
readonly pagination = new Pagination({ pageSize: 25 });
|
|
245
|
+
readonly selection = new Selection<string>();
|
|
246
|
+
|
|
247
|
+
// Getters compose helpers via apply()
|
|
248
|
+
get filtered(): User[] {
|
|
249
|
+
const { search } = this.state;
|
|
250
|
+
let result = this.users.items as User[];
|
|
251
|
+
if (search) {
|
|
252
|
+
const q = search.toLowerCase();
|
|
253
|
+
result = result.filter(u => u.name.toLowerCase().includes(q));
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
get items(): User[] {
|
|
259
|
+
return this.pagination.apply(this.sorting.apply(this.filtered));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
get pageCount(): number {
|
|
263
|
+
return this.pagination.pageCount(this.filtered.length);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Setters ---
|
|
267
|
+
setSearch(search: string) { this.set({ search }); }
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Key points:**
|
|
272
|
+
- Helpers are plain classes, not ViewModels — no `init()` needed.
|
|
273
|
+
- `apply()` is a pure pipeline — chain them in getters: `pagination.apply(sorting.apply(filtered))`.
|
|
274
|
+
- `Selection` is key-based — works with any ID type.
|
|
275
|
+
- `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
|
|
276
|
+
|
|
277
|
+
### Feed Pattern (cursor-based)
|
|
278
|
+
|
|
279
|
+
With Resource (shared data cache):
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
class FeedVM extends ViewModel {
|
|
283
|
+
private resource = singleton(PostsResource);
|
|
284
|
+
readonly feed = new Feed();
|
|
285
|
+
|
|
286
|
+
get items(): Post[] {
|
|
287
|
+
return this.resource.items as Post[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async loadMore() {
|
|
291
|
+
const result = await this.api.getPosts(this.feed.cursor, this.disposeSignal);
|
|
292
|
+
this.resource.upsert(...result.items);
|
|
293
|
+
this.feed.setResult(result);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
protected onInit() {
|
|
297
|
+
this.loadMore();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
With item accumulation (component-scoped):
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
class MessageThreadVM extends ViewModel<{ draft: string }> {
|
|
306
|
+
readonly feed = new Feed<Message>();
|
|
307
|
+
|
|
308
|
+
get messages() { return this.feed.items; }
|
|
309
|
+
|
|
310
|
+
async loadConversation(id: string) {
|
|
311
|
+
this.feed.reset();
|
|
312
|
+
const page = await this.service.getMessages(id, this.disposeSignal);
|
|
313
|
+
this.feed.appendPage(page);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async loadOlder() {
|
|
317
|
+
if (!this.feed.hasMore) return;
|
|
318
|
+
const page = await this.service.getMessages(id, this.disposeSignal, { cursor: this.feed.cursor });
|
|
319
|
+
this.feed.appendPage(page);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Headless Components
|
|
325
|
+
|
|
326
|
+
Pass helpers directly — DataTable duck-types their interfaces:
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
function UsersPage() {
|
|
330
|
+
const [state, vm] = useLocal(UsersListVM, { search: '' });
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div>
|
|
334
|
+
<input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
|
|
335
|
+
<DataTable
|
|
336
|
+
items={vm.items}
|
|
337
|
+
columns={columns}
|
|
338
|
+
sort={vm.sorting}
|
|
339
|
+
selection={vm.selection}
|
|
340
|
+
pagination={vm.pagination}
|
|
341
|
+
paginationTotal={vm.filteredCount}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Components and helpers are independent — use helpers without components, or components without helpers. InfiniteScroll supports `direction="up"` for chat-style reverse scroll.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
232
352
|
## Persistence Pattern
|
|
233
353
|
|
|
234
354
|
```typescript
|
|
@@ -15,13 +15,27 @@ 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
|
+
|
|
23
|
+
## Headless React Components (`mvc-kit/react`)
|
|
24
|
+
|
|
25
|
+
| Component | Description |
|
|
26
|
+
|-----------|-------------|
|
|
27
|
+
| `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helpers directly |
|
|
28
|
+
| `CardList<T>` | Unstyled list/grid with render-prop items |
|
|
29
|
+
| `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
|
|
18
30
|
|
|
19
31
|
## Imports
|
|
20
32
|
|
|
21
33
|
```typescript
|
|
22
34
|
import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
|
|
35
|
+
import { Sorting, Pagination, Selection, Feed } from 'mvc-kit';
|
|
23
36
|
import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
|
|
24
37
|
import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
|
|
38
|
+
import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
|
|
25
39
|
import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
|
|
26
40
|
import { NativeCollection } from 'mvc-kit/react-native';
|
|
27
41
|
```
|
|
@@ -160,6 +174,39 @@ class UserModel extends Model<UserFormState> {
|
|
|
160
174
|
}
|
|
161
175
|
```
|
|
162
176
|
|
|
177
|
+
## Composable Helpers Pattern
|
|
178
|
+
|
|
179
|
+
Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked -- getters that read them recompute when helper state changes.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
class UsersListVM extends ViewModel<FilterState> {
|
|
183
|
+
private users = singleton(UsersResource);
|
|
184
|
+
readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
|
|
185
|
+
readonly pagination = new Pagination({ pageSize: 25 });
|
|
186
|
+
readonly selection = new Selection<string>();
|
|
187
|
+
|
|
188
|
+
get filtered(): User[] {
|
|
189
|
+
const { search } = this.state;
|
|
190
|
+
let result = this.users.items as User[];
|
|
191
|
+
if (search) {
|
|
192
|
+
const q = search.toLowerCase();
|
|
193
|
+
result = result.filter(u => u.name.toLowerCase().includes(q));
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get items(): User[] {
|
|
199
|
+
return this.pagination.apply(this.sorting.apply(this.filtered));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Key points:**
|
|
205
|
+
- Helpers are plain classes, not ViewModels -- no `init()` needed.
|
|
206
|
+
- `apply()` is a pure pipeline -- chain in getters: `pagination.apply(sorting.apply(filtered))`.
|
|
207
|
+
- `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
|
|
208
|
+
- 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}`).
|
|
209
|
+
|
|
163
210
|
## Sharing Patterns
|
|
164
211
|
|
|
165
212
|
1. **Pattern A** (default): Parent ViewModel passes props to presentational children
|
|
@@ -236,6 +283,8 @@ test('example', () => {
|
|
|
236
283
|
- `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
|
|
237
284
|
- `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
|
|
238
285
|
- Pass-through Service wrapping a typed API client → call the client directly from Resource
|
|
286
|
+
- `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
|
|
287
|
+
- Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
|
|
239
288
|
|
|
240
289
|
## Decision Framework
|
|
241
290
|
|
|
@@ -246,6 +295,9 @@ test('example', () => {
|
|
|
246
295
|
- Cross-cutting events → **EventBus**
|
|
247
296
|
- Persistent connection → **Channel**
|
|
248
297
|
- Coordinates multiple ViewModels → **Controller** (rare)
|
|
298
|
+
- Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
|
|
299
|
+
- Cursor-based server pagination → **Feed** helper
|
|
300
|
+
- Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
|
|
249
301
|
|
|
250
302
|
## Dev Mode
|
|
251
303
|
|
|
@@ -15,13 +15,27 @@ 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
|
+
|
|
23
|
+
## Headless React Components (`mvc-kit/react`)
|
|
24
|
+
|
|
25
|
+
| Component | Description |
|
|
26
|
+
|-----------|-------------|
|
|
27
|
+
| `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helpers directly |
|
|
28
|
+
| `CardList<T>` | Unstyled list/grid with render-prop items |
|
|
29
|
+
| `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
|
|
18
30
|
|
|
19
31
|
## Imports
|
|
20
32
|
|
|
21
33
|
```typescript
|
|
22
34
|
import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
|
|
35
|
+
import { Sorting, Pagination, Selection, Feed } from 'mvc-kit';
|
|
23
36
|
import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
|
|
24
37
|
import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
|
|
38
|
+
import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
|
|
25
39
|
import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
|
|
26
40
|
import { NativeCollection } from 'mvc-kit/react-native';
|
|
27
41
|
```
|
|
@@ -160,6 +174,39 @@ class UserModel extends Model<UserFormState> {
|
|
|
160
174
|
}
|
|
161
175
|
```
|
|
162
176
|
|
|
177
|
+
## Composable Helpers Pattern
|
|
178
|
+
|
|
179
|
+
Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
class UsersListVM extends ViewModel<FilterState> {
|
|
183
|
+
private users = singleton(UsersResource);
|
|
184
|
+
readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
|
|
185
|
+
readonly pagination = new Pagination({ pageSize: 25 });
|
|
186
|
+
readonly selection = new Selection<string>();
|
|
187
|
+
|
|
188
|
+
get filtered(): User[] {
|
|
189
|
+
const { search } = this.state;
|
|
190
|
+
let result = this.users.items as User[];
|
|
191
|
+
if (search) {
|
|
192
|
+
const q = search.toLowerCase();
|
|
193
|
+
result = result.filter(u => u.name.toLowerCase().includes(q));
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get items(): User[] {
|
|
199
|
+
return this.pagination.apply(this.sorting.apply(this.filtered));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Key points:**
|
|
205
|
+
- Helpers are plain classes, not ViewModels — no `init()` needed.
|
|
206
|
+
- `apply()` is a pure pipeline — chain in getters: `pagination.apply(sorting.apply(filtered))`.
|
|
207
|
+
- `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
|
|
208
|
+
- 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}`).
|
|
209
|
+
|
|
163
210
|
## Sharing Patterns
|
|
164
211
|
|
|
165
212
|
1. **Pattern A** (default): Parent ViewModel passes props to presentational children
|
|
@@ -236,6 +283,8 @@ test('example', () => {
|
|
|
236
283
|
- `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
|
|
237
284
|
- `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
|
|
238
285
|
- Pass-through Service wrapping a typed API client → call the client directly from Resource
|
|
286
|
+
- `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
|
|
287
|
+
- Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
|
|
239
288
|
|
|
240
289
|
## Decision Framework
|
|
241
290
|
|
|
@@ -246,6 +295,9 @@ test('example', () => {
|
|
|
246
295
|
- Cross-cutting events → **EventBus**
|
|
247
296
|
- Persistent connection → **Channel**
|
|
248
297
|
- Coordinates multiple ViewModels → **Controller** (rare)
|
|
298
|
+
- Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
|
|
299
|
+
- Cursor-based server pagination → **Feed** helper
|
|
300
|
+
- Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
|
|
249
301
|
|
|
250
302
|
## Dev Mode
|
|
251
303
|
|
package/dist/Collection.cjs
CHANGED
|
@@ -134,6 +134,33 @@ class Collection {
|
|
|
134
134
|
throw new Error("Cannot upsert on disposed Collection");
|
|
135
135
|
}
|
|
136
136
|
if (items.length === 0) return;
|
|
137
|
+
if (items.length === 1) {
|
|
138
|
+
const item = items[0];
|
|
139
|
+
const existing = this._index.get(item.id);
|
|
140
|
+
if (existing) {
|
|
141
|
+
if (existing === item) return;
|
|
142
|
+
const prev2 = this._items;
|
|
143
|
+
const idx = this._items.indexOf(existing);
|
|
144
|
+
const newItems = [...prev2];
|
|
145
|
+
newItems[idx] = item;
|
|
146
|
+
this._index.set(item.id, item);
|
|
147
|
+
if (this._timestamps) this._timestamps.set(item.id, Date.now());
|
|
148
|
+
this._items = freeze(newItems);
|
|
149
|
+
this.notify(prev2);
|
|
150
|
+
} else {
|
|
151
|
+
const prev2 = this._items;
|
|
152
|
+
let result2 = [...prev2, item];
|
|
153
|
+
this._index.set(item.id, item);
|
|
154
|
+
if (this._timestamps) this._timestamps.set(item.id, Date.now());
|
|
155
|
+
if (this._maxSize > 0 && result2.length > this._maxSize) {
|
|
156
|
+
result2 = this._evictForCapacity(result2);
|
|
157
|
+
}
|
|
158
|
+
this._items = freeze(result2);
|
|
159
|
+
this.notify(prev2);
|
|
160
|
+
this._scheduleEvictionTimer();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
137
164
|
const incoming = /* @__PURE__ */ new Map();
|
|
138
165
|
for (const item of items) {
|
|
139
166
|
incoming.set(item.id, item);
|
|
@@ -186,6 +213,17 @@ class Collection {
|
|
|
186
213
|
if (ids.length === 0) {
|
|
187
214
|
return;
|
|
188
215
|
}
|
|
216
|
+
if (ids.length === 1) {
|
|
217
|
+
const id = ids[0];
|
|
218
|
+
if (!this._index.has(id)) return;
|
|
219
|
+
const prev2 = this._items;
|
|
220
|
+
this._items = freeze(prev2.filter((item) => item.id !== id));
|
|
221
|
+
this._index.delete(id);
|
|
222
|
+
this._timestamps?.delete(id);
|
|
223
|
+
this.notify(prev2);
|
|
224
|
+
this._scheduleEvictionTimer();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
189
227
|
const idSet = new Set(ids);
|
|
190
228
|
const filtered = this._items.filter((item) => !idSet.has(item.id));
|
|
191
229
|
if (filtered.length === this._items.length) {
|