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 CHANGED
@@ -422,23 +422,141 @@ effect((ctx) => {
422
422
  ```tsx
423
423
  import { pick } from "storion";
424
424
 
425
- function UserName() {
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 profile.name changes
433
- const { name } = useStore(({ get }) => {
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 { name: pick(() => state.profile.name) };
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 you don't want to import singletons directly—it makes testing hard and creates tight coupling.
951
+ **The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
531
952
 
532
- **With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
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
- #### StoreMiddlewareContext
1221
+ #### Middleware Context (Discriminated Union)
738
1222
 
739
- Container middleware uses `StoreMiddlewareContext` where `spec` is always available:
1223
+ Middleware context uses a discriminated union with `type` field:
740
1224
 
741
1225
  ```ts
742
- interface StoreMiddlewareContext<S, A> {
743
- spec: StoreSpec<S, A>; // The store spec (always present)
744
- resolver: Resolver; // The resolver/container instance
745
- next: () => StoreInstance<S, A>; // Call next middleware or create the store
746
- displayName: string; // Store name (always present for stores)
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 StoreMiddleware = <S, A>(
750
- ctx: StoreMiddlewareContext<S, A>
751
- ) => StoreInstance<S, A>;
1245
+ type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
752
1246
  ```
753
1247
 
754
- For generic resolver middleware (non-container), use `Middleware` with `MiddlewareContext`:
1248
+ **Store-specific middleware** (for containers):
755
1249
 
756
1250
  ```ts
757
- interface MiddlewareContext<T> {
758
- factory: Factory<T>; // The factory being invoked
759
- resolver: Resolver; // The resolver instance
760
- next: () => T; // Call next middleware or the factory
761
- displayName?: string; // Name (from factory.displayName or factory.name)
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
- type Middleware = <T>(ctx: MiddlewareContext<T>) => T;
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`)
@@ -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>;