@gusnips/react 0.4.5 → 0.4.6

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
@@ -136,6 +136,22 @@ where there is no window. A session bootstrap can only be in flight in a browser
136
136
  during a build is a wait that never ends: it once shipped a spinner as the indexable body of a
137
137
  page whose whole job was to be found.
138
138
 
139
+ When your store needs more than those flags, `authSlice` in the same place gives you them as a
140
+ plain object to spread, so `create` stays yours — and with it `persist`, your own actions, and
141
+ fields of your own:
142
+
143
+ ```ts
144
+ const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
145
+ ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({
146
+ isAnonymous: !!user?.isAnonymous,
147
+ })),
148
+ }));
149
+ ```
150
+
151
+ The second argument is what makes this more than spreading extra keys in yourself: a field read
152
+ off the user has to be rewritten whenever the user changes, and `setUser` and `clear` are the two
153
+ writes you no longer own.
154
+
139
155
  `hydrateOrMount` is at `@gusnips/react/hydrate`, and the guards are at `@gusnips/react/guards`.
140
156
 
141
157
  ## On a phone
@@ -169,13 +185,13 @@ something.
169
185
  `@gusnips/react` itself needs `react`, `react-dom` and nothing else. Anything that needs more
170
186
  lives behind a subpath, so you install a dependency only if you import the thing that uses it:
171
187
 
172
- | Import from | What is in it | What you must have |
173
- | ------------------------- | ------------------- | ------------------ |
174
- | `@gusnips/react` | the client and rest | react, react-dom |
175
- | `@gusnips/react/store` | `createAuthStore` | zustand |
176
- | `@gusnips/react/guards` | the route guards | react-router-dom |
177
- | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
178
- | `@gusnips/react/contract` | two prerender names | nothing |
188
+ | Import from | What is in it | What you must have |
189
+ | ------------------------- | ------------------------------ | ------------------ |
190
+ | `@gusnips/react` | the client and rest | react, react-dom |
191
+ | `@gusnips/react/store` | `createAuthStore`, `authSlice` | zustand |
192
+ | `@gusnips/react/guards` | the route guards | react-router-dom |
193
+ | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
194
+ | `@gusnips/react/contract` | two prerender names | nothing |
179
195
 
180
196
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
181
197
  point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
@@ -46,4 +46,35 @@ export interface AuthState<TUser> {
46
46
  * Only one of the three donors knew this. It is invariant 7.
47
47
  */
48
48
  export declare function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>>;
49
+ /**
50
+ * zustand's `set`, narrowed to the two writes this slice makes.
51
+ *
52
+ * Not `Partial<AuthState<TUser> & TExtra>`, which is what it means and what a caller passes:
53
+ * inside a generic function TypeScript cannot check `{ isLoading }` against a partial of an
54
+ * unresolved type parameter, because `TExtra` might yet declare an `isLoading` of its own. The
55
+ * union says the same thing in terms it can check at both ends. Any real store's setter
56
+ * satisfies it, where `TExtra` is a concrete type.
57
+ */
58
+ type AuthSet<TUser, TExtra> = (partial: (TExtra & Partial<AuthState<TUser>>) | Pick<AuthState<TUser>, "isLoading">) => void;
59
+ /**
60
+ * The session flags as a plain object, for a product whose store needs more than them.
61
+ *
62
+ * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
63
+ * cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
64
+ * met it had a superset and neither could use it. One holds a remember-me choice, a profile row
65
+ * and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
66
+ * one is the reason this takes a `derive` function rather than just letting the caller spread
67
+ * extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
68
+ * `clear`, which are exactly the two writes the product does not own.
69
+ *
70
+ * The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
71
+ *
72
+ * ```ts
73
+ * const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
74
+ * ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
75
+ * }));
76
+ * ```
77
+ */
78
+ export declare function authSlice<TUser, TExtra extends object>(set: AuthSet<TUser, TExtra>, derive: (user: TUser | null) => TExtra): AuthState<TUser> & TExtra;
79
+ export {};
49
80
  //# sourceMappingURL=auth-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,SAAS,CAAC,KAAK;IAC9B,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,KAAK,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CASlF"}
1
+ {"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,SAAS,CAAC,KAAK;IAC9B,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,KAAK,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAElF;AAKD;;;;;;;;GAQG;AACH,KAAK,OAAO,CAAC,KAAK,EAAE,MAAM,IAAI,CAC5B,OAAO,EAAE,CAAC,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC,KAChF,IAAI,CAAC;AAEV;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,SAAS,MAAM,EACpD,GAAG,EAAE,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,EAC3B,MAAM,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,MAAM,GACrC,SAAS,CAAC,KAAK,CAAC,GAAG,MAAM,CAW3B"}
@@ -15,13 +15,36 @@ import { create } from "zustand";
15
15
  * Only one of the three donors knew this. It is invariant 7.
16
16
  */
17
17
  export function createAuthStore() {
18
- return create((set) => ({
18
+ return create((set) => authSlice(set, () => ({})));
19
+ }
20
+ /**
21
+ * The session flags as a plain object, for a product whose store needs more than them.
22
+ *
23
+ * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
24
+ * cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
25
+ * met it had a superset and neither could use it. One holds a remember-me choice, a profile row
26
+ * and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
27
+ * one is the reason this takes a `derive` function rather than just letting the caller spread
28
+ * extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
29
+ * `clear`, which are exactly the two writes the product does not own.
30
+ *
31
+ * The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
32
+ *
33
+ * ```ts
34
+ * const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
35
+ * ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
36
+ * }));
37
+ * ```
38
+ */
39
+ export function authSlice(set, derive) {
40
+ return {
41
+ ...derive(null),
19
42
  user: null,
20
43
  isAuthenticated: false,
21
44
  isLoading: typeof window !== "undefined",
22
- setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
45
+ setUser: (user) => set({ ...derive(user), user, isAuthenticated: Boolean(user), isLoading: false }),
23
46
  setLoading: (isLoading) => set({ isLoading }),
24
- clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
25
- }));
47
+ clear: () => set({ ...derive(null), user: null, isAuthenticated: false, isLoading: false }),
48
+ };
26
49
  }
27
50
  //# sourceMappingURL=auth-store.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqC,MAAM,SAAS,CAAC;AAkCpE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAmB,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACxC,IAAI,EAAE,IAAI;QACV,eAAe,EAAE,KAAK;QACtB,SAAS,EAAE,OAAO,MAAM,KAAK,WAAW;QACxC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QAClF,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC;QAC7C,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;KAC3E,CAAC,CAAC,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqC,MAAM,SAAS,CAAC;AAkCpE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAmB,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAoB,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC1F,CAAC;AAkBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,SAAS,CACvB,GAA2B,EAC3B,MAAsC;IAEtC,OAAO;QACL,GAAG,MAAM,CAAC,IAAI,CAAC;QACf,IAAI,EAAE,IAAI;QACV,eAAe,EAAE,KAAK;QACtB,SAAS,EAAE,OAAO,MAAM,KAAK,WAAW;QACxC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAChB,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QAClF,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC;QAC7C,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;KAC5F,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { create } from "zustand";
3
+ import { authSlice, createAuthStore, type AuthState } from "./auth-store.ts";
4
+
5
+ interface User {
6
+ id: string;
7
+ email: string;
8
+ isAnonymous?: boolean;
9
+ }
10
+
11
+ describe("createAuthStore", () => {
12
+ it("ends the loading state when it learns who the user is", () => {
13
+ const store = createAuthStore<User>();
14
+
15
+ store.getState().setUser({ id: "1", email: "a@b.c" });
16
+
17
+ expect(store.getState().isAuthenticated).toBe(true);
18
+ expect(store.getState().isLoading).toBe(false);
19
+ });
20
+ });
21
+
22
+ /**
23
+ * The shape an adopter actually has: an anonymous flag read off the user, plus state and an
24
+ * action of its own. Both halves have to survive `setUser` and `clear`, which is the whole
25
+ * reason `authSlice` exists — a store built on `createAuthStore` could hold neither.
26
+ */
27
+ type Extra = { isAnonymous: boolean };
28
+
29
+ const useAuthStore = create<AuthState<User> & Extra & { restored: boolean; restore: () => void }>(
30
+ (set) => ({
31
+ ...authSlice<User, Extra>(set, (user) => ({ isAnonymous: Boolean(user?.isAnonymous) })),
32
+ restored: false,
33
+ restore: () => set({ restored: true }),
34
+ }),
35
+ );
36
+
37
+ describe("authSlice", () => {
38
+ it("keeps a derived field in step with the user it is read from", () => {
39
+ useAuthStore.getState().setUser({ id: "1", email: "a@b.c", isAnonymous: true });
40
+ expect(useAuthStore.getState().isAnonymous).toBe(true);
41
+
42
+ useAuthStore.getState().setUser({ id: "2", email: "d@e.f" });
43
+ expect(useAuthStore.getState().isAnonymous).toBe(false);
44
+
45
+ useAuthStore.getState().setUser({ id: "3", email: "g@h.i", isAnonymous: true });
46
+ // Signing out must not leave the flag behind: the next visitor is not anonymous, they are
47
+ // nobody, and a stale `true` here reads as "signed in anonymously" to every guard.
48
+ useAuthStore.getState().clear();
49
+ expect(useAuthStore.getState().isAnonymous).toBe(false);
50
+ expect(useAuthStore.getState().isAuthenticated).toBe(false);
51
+ });
52
+
53
+ it("leaves the product's own state alone", () => {
54
+ useAuthStore.getState().restore();
55
+ useAuthStore.getState().setUser({ id: "1", email: "a@b.c" });
56
+ useAuthStore.getState().clear();
57
+
58
+ expect(useAuthStore.getState().restored).toBe(true);
59
+ });
60
+ });
package/src/auth-store.ts CHANGED
@@ -48,12 +48,56 @@ export interface AuthState<TUser> {
48
48
  * Only one of the three donors knew this. It is invariant 7.
49
49
  */
50
50
  export function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>> {
51
- return create<AuthState<TUser>>((set) => ({
51
+ return create<AuthState<TUser>>((set) => authSlice<TUser, EmptyExtra>(set, () => ({})));
52
+ }
53
+
54
+ /** What `authSlice` derives when a product adds nothing of its own. */
55
+ type EmptyExtra = Record<never, never>;
56
+
57
+ /**
58
+ * zustand's `set`, narrowed to the two writes this slice makes.
59
+ *
60
+ * Not `Partial<AuthState<TUser> & TExtra>`, which is what it means and what a caller passes:
61
+ * inside a generic function TypeScript cannot check `{ isLoading }` against a partial of an
62
+ * unresolved type parameter, because `TExtra` might yet declare an `isLoading` of its own. The
63
+ * union says the same thing in terms it can check at both ends. Any real store's setter
64
+ * satisfies it, where `TExtra` is a concrete type.
65
+ */
66
+ type AuthSet<TUser, TExtra> = (
67
+ partial: (TExtra & Partial<AuthState<TUser>>) | Pick<AuthState<TUser>, "isLoading">,
68
+ ) => void;
69
+
70
+ /**
71
+ * The session flags as a plain object, for a product whose store needs more than them.
72
+ *
73
+ * `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
74
+ * cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
75
+ * met it had a superset and neither could use it. One holds a remember-me choice, a profile row
76
+ * and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
77
+ * one is the reason this takes a `derive` function rather than just letting the caller spread
78
+ * extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
79
+ * `clear`, which are exactly the two writes the product does not own.
80
+ *
81
+ * The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
82
+ *
83
+ * ```ts
84
+ * const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
85
+ * ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
86
+ * }));
87
+ * ```
88
+ */
89
+ export function authSlice<TUser, TExtra extends object>(
90
+ set: AuthSet<TUser, TExtra>,
91
+ derive: (user: TUser | null) => TExtra,
92
+ ): AuthState<TUser> & TExtra {
93
+ return {
94
+ ...derive(null),
52
95
  user: null,
53
96
  isAuthenticated: false,
54
97
  isLoading: typeof window !== "undefined",
55
- setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
98
+ setUser: (user) =>
99
+ set({ ...derive(user), user, isAuthenticated: Boolean(user), isLoading: false }),
56
100
  setLoading: (isLoading) => set({ isLoading }),
57
- clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
58
- }));
101
+ clear: () => set({ ...derive(null), user: null, isAuthenticated: false, isLoading: false }),
102
+ };
59
103
  }