storion 0.4.0 → 0.5.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 +535 -24
- package/dist/async/async.d.ts +33 -1
- package/dist/async/async.d.ts.map +1 -1
- package/dist/async/index.d.ts +1 -1
- package/dist/async/index.d.ts.map +1 -1
- package/dist/async/index.js +98 -16
- package/dist/core/container.d.ts +6 -12
- package/dist/core/container.d.ts.map +1 -1
- package/dist/core/createResolver.d.ts.map +1 -1
- package/dist/core/middleware.d.ts +25 -12
- package/dist/core/middleware.d.ts.map +1 -1
- package/dist/core/storeContext.d.ts.map +1 -1
- package/dist/devtools/index.js +4 -1
- package/dist/devtools/middleware.d.ts +2 -2
- package/dist/devtools/middleware.d.ts.map +1 -1
- package/dist/devtools-panel/index.js +48 -8
- package/dist/devtools-panel/mount.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -3
- package/dist/{store-Yv-9gPVf.js → store-CwA4YTVb.js} +3 -0
- package/dist/storion.js +172 -226
- package/dist/types.d.ts +76 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -422,23 +422,141 @@ effect((ctx) => {
|
|
|
422
422
|
```tsx
|
|
423
423
|
import { pick } from "storion";
|
|
424
424
|
|
|
425
|
-
function
|
|
425
|
+
function UserProfile() {
|
|
426
426
|
// Without pick: re-renders when ANY profile property changes
|
|
427
427
|
const { name } = useStore(({ get }) => {
|
|
428
428
|
const [state] = get(userStore);
|
|
429
429
|
return { name: state.profile.name };
|
|
430
430
|
});
|
|
431
431
|
|
|
432
|
-
// With pick: re-renders ONLY when
|
|
433
|
-
|
|
432
|
+
// With pick: re-renders ONLY when the picked value changes
|
|
433
|
+
// Multiple picks can be used in one selector
|
|
434
|
+
const { name, fullName, coords, settings, nested } = useStore(({ get }) => {
|
|
434
435
|
const [state] = get(userStore);
|
|
435
|
-
return {
|
|
436
|
+
return {
|
|
437
|
+
// Simple pick - uses default equality (===)
|
|
438
|
+
name: pick(() => state.profile.name),
|
|
439
|
+
|
|
440
|
+
// Computed value - only re-renders when result changes
|
|
441
|
+
fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
|
|
442
|
+
|
|
443
|
+
// 'shallow' - compares object properties one level deep
|
|
444
|
+
coords: pick(
|
|
445
|
+
() => ({ x: state.position.x, y: state.position.y }),
|
|
446
|
+
"shallow"
|
|
447
|
+
),
|
|
448
|
+
|
|
449
|
+
// 'deep' - recursively compares nested objects/arrays
|
|
450
|
+
settings: pick(() => state.userSettings, "deep"),
|
|
451
|
+
|
|
452
|
+
// Custom equality function - full control
|
|
453
|
+
nested: pick(
|
|
454
|
+
() => state.data.items.map((i) => i.id),
|
|
455
|
+
(a, b) => a.length === b.length && a.every((id, i) => id === b[i])
|
|
456
|
+
),
|
|
457
|
+
};
|
|
436
458
|
});
|
|
437
459
|
|
|
438
460
|
return <h1>{name}</h1>;
|
|
439
461
|
}
|
|
440
462
|
```
|
|
441
463
|
|
|
464
|
+
### Understanding Equality: Store vs Component Level
|
|
465
|
+
|
|
466
|
+
Storion provides two layers of equality control, each solving different problems:
|
|
467
|
+
|
|
468
|
+
| Layer | API | When it runs | Purpose |
|
|
469
|
+
| -------------------- | ----------------- | --------------------- | ------------------------------------------------ |
|
|
470
|
+
| **Store (write)** | `equality` option | When state is mutated | Prevent unnecessary notifications to subscribers |
|
|
471
|
+
| **Component (read)** | `pick(fn, eq)` | When selector runs | Prevent unnecessary re-renders |
|
|
472
|
+
|
|
473
|
+
```
|
|
474
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
475
|
+
│ Store │
|
|
476
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
477
|
+
│ │ state.coords = { x: 1, y: 2 } │ │
|
|
478
|
+
│ │ │ │ │
|
|
479
|
+
│ │ ▼ │ │
|
|
480
|
+
│ │ equality: { coords: "shallow" } ──► Same x,y? Skip notify │ │
|
|
481
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
482
|
+
│ │ │
|
|
483
|
+
│ notify if changed │
|
|
484
|
+
│ ▼ │
|
|
485
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
486
|
+
│ │ Component A │ Component B │ │
|
|
487
|
+
│ │ pick(() => coords.x) │ pick(() => coords, "shallow")│ │
|
|
488
|
+
│ │ │ │ │ │ │
|
|
489
|
+
│ │ ▼ │ ▼ │ │
|
|
490
|
+
│ │ Re-render if x changed │ Re-render if x OR y changed │ │
|
|
491
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
492
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
**Example: Coordinates update**
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
// Store level - controls when subscribers get notified
|
|
499
|
+
const mapStore = store({
|
|
500
|
+
state: { coords: { x: 0, y: 0 }, zoom: 1 },
|
|
501
|
+
equality: {
|
|
502
|
+
coords: "shallow", // Don't notify if same { x, y } values
|
|
503
|
+
},
|
|
504
|
+
setup({ state }) {
|
|
505
|
+
return {
|
|
506
|
+
setCoords: (x: number, y: number) => {
|
|
507
|
+
state.coords = { x, y }; // New object, but shallow-equal = no notify
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Component level - controls when THIS component re-renders
|
|
514
|
+
function XCoordinate() {
|
|
515
|
+
const { x } = useStore(({ get }) => {
|
|
516
|
+
const [state] = get(mapStore);
|
|
517
|
+
return {
|
|
518
|
+
// Even if coords changed, only re-render if x specifically changed
|
|
519
|
+
x: pick(() => state.coords.x),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
return <span>X: {x}</span>;
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Comparison with Other Libraries
|
|
527
|
+
|
|
528
|
+
| Feature | Storion | Redux | Zustand | Jotai | MobX |
|
|
529
|
+
| ------------------ | --------------------- | ------------------------ | ---------------- | ----------------- | --------------- |
|
|
530
|
+
| **Tracking** | Automatic | Manual selectors | Manual selectors | Automatic (atoms) | Automatic |
|
|
531
|
+
| **Write equality** | Per-property config | Reducer-based | Built-in shallow | Per-atom | Deep by default |
|
|
532
|
+
| **Read equality** | `pick()` with options | `useSelector` + equality | `shallow` helper | Atom-level | Computed |
|
|
533
|
+
| **Granularity** | Property + component | Selector-based | Selector-based | Atom-based | Property-based |
|
|
534
|
+
| **Bundle size** | ~4KB | ~2KB + toolkit | ~1KB | ~2KB | ~15KB |
|
|
535
|
+
| **DI / Lifecycle** | Built-in container | External (thunk/saga) | External | Provider-based | External |
|
|
536
|
+
|
|
537
|
+
**Key differences:**
|
|
538
|
+
|
|
539
|
+
- **Redux/Zustand**: You write selectors manually and pass equality functions to `useSelector`. Easy to forget and over-subscribe.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
// Zustand - must remember to add shallow
|
|
543
|
+
const coords = useStore((s) => s.coords, shallow);
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
- **Jotai**: Fine-grained via atoms, but requires splitting state into many atoms upfront.
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
// Jotai - must create separate atoms
|
|
550
|
+
const xAtom = atom((get) => get(coordsAtom).x);
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
- **Storion**: Auto-tracking by default, `pick()` for opt-in fine-grained control, store-level equality for write optimization.
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// Storion - automatic tracking, pick() when you need precision
|
|
557
|
+
const x = pick(() => state.coords.x);
|
|
558
|
+
```
|
|
559
|
+
|
|
442
560
|
### Async State Management
|
|
443
561
|
|
|
444
562
|
**The problem:** Every async operation needs loading, error, and success states. You write the same boilerplate: `isLoading`, `error`, `data`, plus handling race conditions, retries, and cancellation.
|
|
@@ -525,11 +643,323 @@ function ProductList() {
|
|
|
525
643
|
}
|
|
526
644
|
```
|
|
527
645
|
|
|
646
|
+
### When to Fetch Data
|
|
647
|
+
|
|
648
|
+
Storion provides multiple patterns for data fetching. Choose based on your use case:
|
|
649
|
+
|
|
650
|
+
| Pattern | When to use | Example |
|
|
651
|
+
| ----------------------- | ---------------------------------------------- | ---------------------------------- |
|
|
652
|
+
| **Setup time** | Data needed immediately when store initializes | App config, user session |
|
|
653
|
+
| **Trigger (no deps)** | One-time fetch when component mounts | Initial page data |
|
|
654
|
+
| **Trigger (with deps)** | Refetch when component visits or deps change | Dashboard refresh |
|
|
655
|
+
| **useEffect** | Standard React pattern, explicit control | Compatibility with existing code |
|
|
656
|
+
| **User interaction** | On-demand fetching | Search, pagination, refresh button |
|
|
657
|
+
|
|
658
|
+
```tsx
|
|
659
|
+
import { store } from "storion";
|
|
660
|
+
import { async, type AsyncState } from "storion/async";
|
|
661
|
+
import { useStore } from "storion/react";
|
|
662
|
+
import { useEffect } from "react";
|
|
663
|
+
|
|
664
|
+
interface User {
|
|
665
|
+
id: string;
|
|
666
|
+
name: string;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export const userStore = store({
|
|
670
|
+
name: "users",
|
|
671
|
+
state: {
|
|
672
|
+
currentUser: async.fresh<User>(),
|
|
673
|
+
searchResults: async.stale<User[]>([]),
|
|
674
|
+
},
|
|
675
|
+
setup({ focus, effect }) {
|
|
676
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
677
|
+
// Pattern 1: Fetch at SETUP TIME
|
|
678
|
+
// Data is fetched immediately when store is created
|
|
679
|
+
// Good for: App config, auth state, critical data
|
|
680
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
681
|
+
const currentUserAsync = async(focus("currentUser"), async (ctx) => {
|
|
682
|
+
const res = await fetch("/api/me", { signal: ctx.signal });
|
|
683
|
+
return res.json();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Fetch immediately during setup
|
|
687
|
+
currentUserAsync.dispatch();
|
|
688
|
+
|
|
689
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
690
|
+
// Pattern 2: Expose DISPATCH for UI control
|
|
691
|
+
// Store provides action, UI decides when to call
|
|
692
|
+
// Good for: Search, pagination, user-triggered refresh
|
|
693
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
694
|
+
const searchAsync = async(
|
|
695
|
+
focus("searchResults"),
|
|
696
|
+
async (ctx, query: string) => {
|
|
697
|
+
const res = await fetch(`/api/users/search?q=${query}`, {
|
|
698
|
+
signal: ctx.signal,
|
|
699
|
+
});
|
|
700
|
+
return res.json();
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
currentUser: currentUserAsync,
|
|
706
|
+
search: searchAsync.dispatch,
|
|
707
|
+
cancelSearch: searchAsync.cancel,
|
|
708
|
+
};
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
713
|
+
// Pattern 3: TRIGGER with dependencies
|
|
714
|
+
// Uses useStore's `trigger` for declarative data fetching
|
|
715
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
716
|
+
|
|
717
|
+
// 3a. No deps - fetch ONCE when component mounts
|
|
718
|
+
function UserProfile() {
|
|
719
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
720
|
+
const [state, actions] = get(userStore);
|
|
721
|
+
|
|
722
|
+
// No deps array = fetch once, never refetch
|
|
723
|
+
trigger(actions.currentUser.dispatch, []);
|
|
724
|
+
|
|
725
|
+
return { user: state.currentUser };
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
if (user.status === "pending") return <Spinner />;
|
|
729
|
+
return <div>{user.data?.name}</div>;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 3b. With context.id - refetch EVERY TIME component visits
|
|
733
|
+
function Dashboard() {
|
|
734
|
+
const { user } = useStore(({ get, trigger, id }) => {
|
|
735
|
+
const [state, actions] = get(userStore);
|
|
736
|
+
|
|
737
|
+
// `id` changes each time component mounts = refetch on every visit
|
|
738
|
+
trigger(actions.currentUser.dispatch, [id]);
|
|
739
|
+
|
|
740
|
+
return { user: state.currentUser };
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return <div>Welcome back, {user.data?.name}</div>;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 3c. With custom deps - refetch when deps change
|
|
747
|
+
function UserById({ userId }: { userId: string }) {
|
|
748
|
+
const { user } = useStore(
|
|
749
|
+
({ get, trigger }) => {
|
|
750
|
+
const [state, actions] = get(userStore);
|
|
751
|
+
|
|
752
|
+
// Refetch when userId prop changes
|
|
753
|
+
trigger(() => actions.currentUser.dispatch(), [userId]);
|
|
754
|
+
|
|
755
|
+
return { user: state.currentUser };
|
|
756
|
+
},
|
|
757
|
+
[userId] // selector deps for proper tracking
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
return <div>{user.data?.name}</div>;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
764
|
+
// Pattern 4: useEffect - standard React pattern
|
|
765
|
+
// For compatibility or when you need more control
|
|
766
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
767
|
+
function UserListWithEffect() {
|
|
768
|
+
const { search } = useStore(({ get }) => {
|
|
769
|
+
const [, actions] = get(userStore);
|
|
770
|
+
return { search: actions.search };
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
search("initial");
|
|
775
|
+
}, []);
|
|
776
|
+
|
|
777
|
+
return <div>...</div>;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
781
|
+
// Pattern 5: USER INTERACTION - on-demand fetching
|
|
782
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
783
|
+
function SearchBox() {
|
|
784
|
+
const [query, setQuery] = useState("");
|
|
785
|
+
const { results, search, cancel } = useStore(({ get }) => {
|
|
786
|
+
const [state, actions] = get(userStore);
|
|
787
|
+
return {
|
|
788
|
+
results: state.searchResults,
|
|
789
|
+
search: actions.search,
|
|
790
|
+
cancel: actions.cancelSearch,
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const handleSearch = () => {
|
|
795
|
+
cancel(); // Cancel previous search
|
|
796
|
+
search(query);
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div>
|
|
801
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
802
|
+
<button onClick={handleSearch}>Search</button>
|
|
803
|
+
<button onClick={cancel}>Cancel</button>
|
|
804
|
+
|
|
805
|
+
{results.status === "pending" && <Spinner />}
|
|
806
|
+
{results.data?.map((user) => (
|
|
807
|
+
<div key={user.id}>{user.name}</div>
|
|
808
|
+
))}
|
|
809
|
+
</div>
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
**Summary: Choosing the right pattern**
|
|
815
|
+
|
|
816
|
+
```
|
|
817
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
818
|
+
│ When should data be fetched? │
|
|
819
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
820
|
+
│ │
|
|
821
|
+
│ App starts? ──────────► Setup time (dispatch in setup) │
|
|
822
|
+
│ │
|
|
823
|
+
│ Component mounts? │
|
|
824
|
+
│ │ │
|
|
825
|
+
│ ├── Once ever? ────► trigger(fn, []) │
|
|
826
|
+
│ │ │
|
|
827
|
+
│ ├── Every visit? ──► trigger(fn, [id]) │
|
|
828
|
+
│ │ │
|
|
829
|
+
│ └── When deps change? ► trigger(fn, [dep1, dep2]) │
|
|
830
|
+
│ │
|
|
831
|
+
│ User clicks? ──────────► onClick handler calls action │
|
|
832
|
+
│ │
|
|
833
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Suspense Pattern with `async.wait()`
|
|
837
|
+
|
|
838
|
+
**The problem:** You want to use React Suspense for loading states, but managing the "throw promise" pattern manually is complex and error-prone.
|
|
839
|
+
|
|
840
|
+
**With Storion:** Use `async.wait()` to extract data from async state — it throws a promise if pending (triggering Suspense) or throws the error if failed.
|
|
841
|
+
|
|
842
|
+
```tsx
|
|
843
|
+
import { Suspense } from "react";
|
|
844
|
+
import { async } from "storion/async";
|
|
845
|
+
import { useStore } from "storion/react";
|
|
846
|
+
|
|
847
|
+
// Component that uses Suspense - no loading/error handling needed!
|
|
848
|
+
function UserProfile() {
|
|
849
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
850
|
+
const [state, actions] = get(userStore);
|
|
851
|
+
|
|
852
|
+
// Trigger fetch on mount
|
|
853
|
+
trigger(actions.fetchUser, []);
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
// async.wait() throws if pending/error, returns data if success
|
|
857
|
+
user: async.wait(state.currentUser),
|
|
858
|
+
};
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// This only renders when data is ready
|
|
862
|
+
return (
|
|
863
|
+
<div>
|
|
864
|
+
<h1>{user.name}</h1>
|
|
865
|
+
<p>{user.email}</p>
|
|
866
|
+
</div>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Parent wraps with Suspense and ErrorBoundary
|
|
871
|
+
function App() {
|
|
872
|
+
return (
|
|
873
|
+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
874
|
+
<Suspense fallback={<Spinner />}>
|
|
875
|
+
<UserProfile />
|
|
876
|
+
</Suspense>
|
|
877
|
+
</ErrorBoundary>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
**Multiple async states with `async.all()`:**
|
|
883
|
+
|
|
884
|
+
```tsx
|
|
885
|
+
function Dashboard() {
|
|
886
|
+
const { user, posts, comments } = useStore(({ get, trigger }) => {
|
|
887
|
+
const [userState, userActions] = get(userStore);
|
|
888
|
+
const [postState, postActions] = get(postStore);
|
|
889
|
+
const [commentState, commentActions] = get(commentStore);
|
|
890
|
+
|
|
891
|
+
trigger(userActions.fetch, []);
|
|
892
|
+
trigger(postActions.fetch, []);
|
|
893
|
+
trigger(commentActions.fetch, []);
|
|
894
|
+
|
|
895
|
+
// Wait for ALL async states - suspends until all are ready
|
|
896
|
+
const [user, posts, comments] = async.all(
|
|
897
|
+
userState.current,
|
|
898
|
+
postState.list,
|
|
899
|
+
commentState.recent
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
return { user, posts, comments };
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
return (
|
|
906
|
+
<div>
|
|
907
|
+
<h1>Welcome, {user.name}</h1>
|
|
908
|
+
<PostList posts={posts} />
|
|
909
|
+
<CommentList comments={comments} />
|
|
910
|
+
</div>
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
**Race pattern with `async.race()`:**
|
|
916
|
+
|
|
917
|
+
```tsx
|
|
918
|
+
function FastestResult() {
|
|
919
|
+
const { result } = useStore(({ get, trigger }) => {
|
|
920
|
+
const [state, actions] = get(searchStore);
|
|
921
|
+
|
|
922
|
+
trigger(() => {
|
|
923
|
+
actions.searchAPI1(query);
|
|
924
|
+
actions.searchAPI2(query);
|
|
925
|
+
}, [query]);
|
|
926
|
+
|
|
927
|
+
// Returns whichever finishes first
|
|
928
|
+
return {
|
|
929
|
+
result: async.race(state.api1Results, state.api2Results),
|
|
930
|
+
};
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
return <ResultList items={result} />;
|
|
934
|
+
}
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
**Async helpers summary:**
|
|
938
|
+
|
|
939
|
+
| Helper | Behavior | Use case |
|
|
940
|
+
| ------------------------ | ------------------------------------- | ------------------------- |
|
|
941
|
+
| `async.wait(state)` | Throws if pending/error, returns data | Single Suspense resource |
|
|
942
|
+
| `async.all(...states)` | Waits for all, returns tuple | Multiple parallel fetches |
|
|
943
|
+
| `async.any(...states)` | Returns first successful | Fallback sources |
|
|
944
|
+
| `async.race(states)` | Returns fastest | Competitive fetching |
|
|
945
|
+
| `async.hasData(state)` | `boolean` | Check without suspending |
|
|
946
|
+
| `async.isLoading(state)` | `boolean` | Loading indicator |
|
|
947
|
+
| `async.isError(state)` | `boolean` | Error check |
|
|
948
|
+
|
|
528
949
|
### Dependency Injection
|
|
529
950
|
|
|
530
|
-
**The problem:** Your stores need shared services (API clients, loggers, config) but
|
|
951
|
+
**The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
|
|
531
952
|
|
|
532
|
-
**
|
|
953
|
+
- **No lifecycle management** — ES imports are forever; you can't dispose or recreate instances
|
|
954
|
+
- **Testing is painful** — Mocking ES modules requires awkward workarounds
|
|
955
|
+
- **No cleanup** — Resources like connections, intervals, or subscriptions leak between tests
|
|
956
|
+
|
|
957
|
+
**With Storion:** The container is a full DI system that manages the complete lifecycle:
|
|
958
|
+
|
|
959
|
+
- **Automatic caching** — Services are singletons by default, created on first use
|
|
960
|
+
- **Dispose & cleanup** — Call `dispose()` to clean up resources, `delete()` to remove and recreate
|
|
961
|
+
- **Override for testing** — Swap implementations with `set()` without touching module imports
|
|
962
|
+
- **Hierarchical containers** — Create child containers for scoped dependencies
|
|
533
963
|
|
|
534
964
|
```ts
|
|
535
965
|
import { container, type Resolver } from "storion";
|
|
@@ -576,6 +1006,24 @@ const userStore = store({
|
|
|
576
1006
|
};
|
|
577
1007
|
},
|
|
578
1008
|
});
|
|
1009
|
+
|
|
1010
|
+
// Testing - easy to mock without module mocking
|
|
1011
|
+
const mockApi: ApiService = {
|
|
1012
|
+
get: async () => ({ id: "1", name: "Test User" }),
|
|
1013
|
+
post: async () => ({}),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const testApp = container();
|
|
1017
|
+
testApp.set(createApiService, () => mockApi); // Override with mock
|
|
1018
|
+
|
|
1019
|
+
// Now userStore will use mockApi instead of real API
|
|
1020
|
+
const { actions } = testApp.get(userStore);
|
|
1021
|
+
await actions.fetchUser("1"); // Uses mockApi.get()
|
|
1022
|
+
|
|
1023
|
+
// Lifecycle management
|
|
1024
|
+
testApp.delete(createApiService); // Remove cached instance
|
|
1025
|
+
testApp.clear(); // Clear all cached instances
|
|
1026
|
+
testApp.dispose(); // Dispose container and all instances
|
|
579
1027
|
```
|
|
580
1028
|
|
|
581
1029
|
### Middleware
|
|
@@ -656,6 +1104,42 @@ interface StoreOptions<TState, TActions> {
|
|
|
656
1104
|
}
|
|
657
1105
|
```
|
|
658
1106
|
|
|
1107
|
+
**Per-property equality** — Configure different equality checks for each state property:
|
|
1108
|
+
|
|
1109
|
+
```ts
|
|
1110
|
+
const myStore = store({
|
|
1111
|
+
name: "settings",
|
|
1112
|
+
state: {
|
|
1113
|
+
theme: "light",
|
|
1114
|
+
coords: { x: 0, y: 0 },
|
|
1115
|
+
items: [] as string[],
|
|
1116
|
+
config: { nested: { deep: true } },
|
|
1117
|
+
},
|
|
1118
|
+
// Per-property equality configuration
|
|
1119
|
+
equality: {
|
|
1120
|
+
theme: "strict", // Default (===)
|
|
1121
|
+
coords: "shallow", // Compare { x, y } properties
|
|
1122
|
+
items: "shallow", // Compare array elements
|
|
1123
|
+
config: "deep", // Deep recursive comparison
|
|
1124
|
+
},
|
|
1125
|
+
setup({ state }) {
|
|
1126
|
+
return {
|
|
1127
|
+
setCoords: (x: number, y: number) => {
|
|
1128
|
+
// Only triggers subscribers if x or y actually changed (shallow compare)
|
|
1129
|
+
state.coords = { x, y };
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
| Equality | Description |
|
|
1137
|
+
| ------------------- | ----------------------------------------------- |
|
|
1138
|
+
| `"strict"` | Default `===` comparison |
|
|
1139
|
+
| `"shallow"` | Compares object/array properties one level deep |
|
|
1140
|
+
| `"deep"` | Recursively compares nested structures |
|
|
1141
|
+
| `(a, b) => boolean` | Custom comparison function |
|
|
1142
|
+
|
|
659
1143
|
#### StoreContext (in setup)
|
|
660
1144
|
|
|
661
1145
|
```ts
|
|
@@ -734,34 +1218,61 @@ interface AsyncState<T, M extends "fresh" | "stale"> {
|
|
|
734
1218
|
| `applyFor` | Apply middleware conditionally (pattern/predicate) |
|
|
735
1219
|
| `applyExcept` | Apply middleware except for matching patterns |
|
|
736
1220
|
|
|
737
|
-
####
|
|
1221
|
+
#### Middleware Context (Discriminated Union)
|
|
738
1222
|
|
|
739
|
-
|
|
1223
|
+
Middleware context uses a discriminated union with `type` field:
|
|
740
1224
|
|
|
741
1225
|
```ts
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1226
|
+
// For stores (container middleware)
|
|
1227
|
+
interface StoreMiddlewareContext {
|
|
1228
|
+
type: "store"; // Discriminant
|
|
1229
|
+
spec: StoreSpec; // Always present for stores
|
|
1230
|
+
factory: Factory;
|
|
1231
|
+
resolver: Resolver;
|
|
1232
|
+
next: () => unknown;
|
|
1233
|
+
displayName: string; // Always present for stores
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// For plain factories (resolver middleware)
|
|
1237
|
+
interface FactoryMiddlewareContext {
|
|
1238
|
+
type: "factory"; // Discriminant
|
|
1239
|
+
factory: Factory;
|
|
1240
|
+
resolver: Resolver;
|
|
1241
|
+
next: () => unknown;
|
|
1242
|
+
displayName: string | undefined;
|
|
747
1243
|
}
|
|
748
1244
|
|
|
749
|
-
type
|
|
750
|
-
ctx: StoreMiddlewareContext<S, A>
|
|
751
|
-
) => StoreInstance<S, A>;
|
|
1245
|
+
type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
|
|
752
1246
|
```
|
|
753
1247
|
|
|
754
|
-
|
|
1248
|
+
**Store-specific middleware** (for containers):
|
|
755
1249
|
|
|
756
1250
|
```ts
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1251
|
+
// No generics needed - simple and clean
|
|
1252
|
+
type StoreMiddleware = (ctx: StoreMiddlewareContext) => StoreInstance;
|
|
1253
|
+
|
|
1254
|
+
const loggingMiddleware: StoreMiddleware = (ctx) => {
|
|
1255
|
+
console.log(`Creating: ${ctx.displayName}`);
|
|
1256
|
+
const instance = ctx.next();
|
|
1257
|
+
console.log(`Created: ${instance.id}`);
|
|
1258
|
+
return instance as StoreInstance;
|
|
1259
|
+
};
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
**Generic middleware** (for resolver, works with both stores and factories):
|
|
763
1263
|
|
|
764
|
-
|
|
1264
|
+
```ts
|
|
1265
|
+
type Middleware = (ctx: MiddlewareContext) => unknown;
|
|
1266
|
+
|
|
1267
|
+
const loggingMiddleware: Middleware = (ctx) => {
|
|
1268
|
+
// Use type narrowing
|
|
1269
|
+
if (ctx.type === "store") {
|
|
1270
|
+
console.log(`Store: ${ctx.spec.displayName}`);
|
|
1271
|
+
} else {
|
|
1272
|
+
console.log(`Factory: ${ctx.displayName ?? "anonymous"}`);
|
|
1273
|
+
}
|
|
1274
|
+
return ctx.next();
|
|
1275
|
+
};
|
|
765
1276
|
```
|
|
766
1277
|
|
|
767
1278
|
### Devtools (`storion/devtools`)
|
package/dist/async/async.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Focus } from '../types';
|
|
2
|
-
import { AsyncState, AsyncMode, AsyncHandler, AsyncOptions, AsyncActions, CancellablePromise, InferAsyncData, MapAsyncData, MapSettledResult, RaceResult } from './types';
|
|
2
|
+
import { AsyncState, AsyncMode, AsyncHandler, AsyncOptions, AsyncActions, CancellablePromise, InferAsyncData, MapAsyncData, MapSettledResult, RaceResult, AsyncKey, AsyncRequestId } from './types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Get the pending promise for an async state (for Suspense).
|
|
@@ -12,14 +12,46 @@ export declare function getPendingPromise<T>(state: AsyncState<T, any>): Promise
|
|
|
12
12
|
*/
|
|
13
13
|
declare function promiseTry<T>(fn: () => T | PromiseLike<T>): Promise<Awaited<T>>;
|
|
14
14
|
export declare function async<T, M extends AsyncMode, TArgs extends any[]>(focus: Focus<AsyncState<T, M>>, handler: AsyncHandler<T, TArgs>, options?: AsyncOptions): AsyncActions<T, M, TArgs>;
|
|
15
|
+
/**
|
|
16
|
+
* Extra properties that can be added to async state.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
interface AsyncStateExtra<T> {
|
|
20
|
+
__key?: AsyncKey<T>;
|
|
21
|
+
__requestId?: AsyncRequestId;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a frozen AsyncState with the specified status.
|
|
25
|
+
* Users cannot modify properties directly - must use async actions.
|
|
26
|
+
*
|
|
27
|
+
* Overloads:
|
|
28
|
+
* - asyncState("fresh", "idle") - Fresh idle state
|
|
29
|
+
* - asyncState("fresh", "pending", extra?) - Fresh pending state
|
|
30
|
+
* - asyncState("fresh", "success", data) - Fresh success state
|
|
31
|
+
* - asyncState("fresh", "error", error, extra?) - Fresh error state
|
|
32
|
+
* - asyncState("stale", "idle", data) - Stale idle state
|
|
33
|
+
* - asyncState("stale", "pending", data, extra?) - Stale pending state
|
|
34
|
+
* - asyncState("stale", "success", data) - Stale success state
|
|
35
|
+
* - asyncState("stale", "error", data, error, extra?) - Stale error state
|
|
36
|
+
*/
|
|
37
|
+
export declare function asyncState<T = unknown>(mode: "fresh", status: "idle"): AsyncState<T, "fresh">;
|
|
38
|
+
export declare function asyncState<T = unknown>(mode: "fresh", status: "pending", extra?: AsyncStateExtra<T>): AsyncState<T, "fresh">;
|
|
39
|
+
export declare function asyncState<T>(mode: "fresh", status: "success", data: T): AsyncState<T, "fresh">;
|
|
40
|
+
export declare function asyncState<T = unknown>(mode: "fresh", status: "error", error: Error, extra?: AsyncStateExtra<T>): AsyncState<T, "fresh">;
|
|
41
|
+
export declare function asyncState<T>(mode: "stale", status: "idle", data: T): AsyncState<T, "stale">;
|
|
42
|
+
export declare function asyncState<T>(mode: "stale", status: "pending", data: T, extra?: AsyncStateExtra<T>): AsyncState<T, "stale">;
|
|
43
|
+
export declare function asyncState<T>(mode: "stale", status: "success", data: T): AsyncState<T, "stale">;
|
|
44
|
+
export declare function asyncState<T>(mode: "stale", status: "error", data: T, error: Error, extra?: AsyncStateExtra<T>): AsyncState<T, "stale">;
|
|
15
45
|
export declare namespace async {
|
|
16
46
|
/**
|
|
17
47
|
* Create a fresh mode async state (data undefined during loading/error).
|
|
48
|
+
* @deprecated Use `asyncState("fresh", "idle")` for explicit state creation
|
|
18
49
|
*/
|
|
19
50
|
function fresh<T = unknown>(): AsyncState<T, "fresh">;
|
|
20
51
|
/**
|
|
21
52
|
* Create a stale mode async state with initial data.
|
|
22
53
|
* Data is preserved during loading and error states.
|
|
54
|
+
* @deprecated Use `asyncState("stale", "idle", initialData)` for explicit state creation
|
|
23
55
|
*/
|
|
24
56
|
function stale<T>(initialData: T): AsyncState<T, "stale">;
|
|
25
57
|
function delay<T = void>(ms: number, resolved?: T): CancellablePromise<T>;
|