@kitr/state 0.3.1 → 0.4.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/dist/index.d.ts CHANGED
@@ -10,11 +10,17 @@ export interface KitrStoreOptions<T> {
10
10
  persist?: Partial<PersistOptions<T, Partial<T>>> | boolean;
11
11
  /** Connect to the Redux DevTools extension in development. Default `true`; never in production or on the server. */
12
12
  devtools?: boolean;
13
+ /**
14
+ * Throw when a component reads the store on the server outside its `<StoreProvider>`, instead of
15
+ * rendering a fresh copy of the initial state. Default `false`.
16
+ */
17
+ strict?: boolean;
13
18
  }
14
19
  /**
15
20
  * An SSR-safe Zustand store factory. On the client the store is a module-level
16
21
  * singleton; on the server (or in tests) you get a fresh store per `<StoreProvider>`,
17
- * so one user's state never leaks into another's render.
22
+ * so one user's state never leaks into another's render. Without a provider, each component
23
+ * that reads the store on the server gets its own fresh store, built from the initial state.
18
24
  *
19
25
  * const [useCounter, CounterProvider, counterStore] = createKitrStore({ name: 'counter' }, (set) => ({ ... }))
20
26
  */
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ export * from "zustand";
7
7
  /**
8
8
  * An SSR-safe Zustand store factory. On the client the store is a module-level
9
9
  * singleton; on the server (or in tests) you get a fresh store per `<StoreProvider>`,
10
- * so one user's state never leaks into another's render.
10
+ * so one user's state never leaks into another's render. Without a provider, each component
11
+ * that reads the store on the server gets its own fresh store, built from the initial state.
11
12
  *
12
13
  * const [useCounter, CounterProvider, counterStore] = createKitrStore({ name: 'counter' }, (set) => ({ ... }))
13
14
  */
@@ -54,8 +55,14 @@ function createKitrStore(options, initializer) {
54
55
  });
55
56
  }
56
57
  function useKitrStore(selector) {
57
- const store = useContext(Context) ?? singleton;
58
- if (!store) throw new Error(`[@kitr/state] Store "${options.name}" has no instance on the server. Wrap the tree in its <StoreProvider>.`);
58
+ const fromContext = useContext(Context);
59
+ const own = useRef(null);
60
+ let store = fromContext ?? singleton;
61
+ if (!store) {
62
+ if (options.strict) throw new Error(`[@kitr/state] Store "${options.name}" has no instance on the server. Wrap the tree in its <StoreProvider>.`);
63
+ own.current ??= build();
64
+ store = own.current;
65
+ }
59
66
  const select = selector ?? ((s) => s);
60
67
  const snapshot = () => select(store.getState());
61
68
  const serverSnapshot = options.persist && store === singleton ? () => select(renderedOnServer(store)) : snapshot;
package/dist/kitr.d.ts CHANGED
@@ -13,6 +13,7 @@ declare const _default: {
13
13
  vars: Record<string, string>;
14
14
  };
15
15
  readonly stub: ({ name }: import("@kitr/core/package").GeneratorVars) => string;
16
+ readonly after: (ctx: import("@kitr/core/package").AddContext) => void;
16
17
  };
17
18
  };
18
19
  readonly wraps: {
package/dist/kitr.js CHANGED
@@ -7,7 +7,7 @@ var kitr_default = definePackage({
7
7
  add(ctx) {
8
8
  ctx.deps.add("zustand");
9
9
  ctx.write("app/stores/.gitkeep", "");
10
- ctx.agents.add("state", `- Client state only. \`kitr make:store <name>\` writes \`app/stores/<name>.ts\` using \`createKitrStore\`. Server data belongs in queries, not stores.`);
10
+ ctx.agents.add("state", `- Client state only. \`kitr make:store <name>\` writes \`app/stores/<name>.ts\` using \`createKitrStore\`. On the server, wrap the components that use it in its \`<NameStoreProvider>\` to share one store per render; without it each component renders the initial state. Server data belongs in queries, not stores.`);
11
11
  },
12
12
  remove(ctx) {
13
13
  ctx.deps.remove("zustand");
@@ -25,6 +25,11 @@ interface ${naming.pascal(name)}State {
25
25
  reset: () => void
26
26
  }
27
27
 
28
+ /**
29
+ * In the browser \`use${naming.pascal(name)}Store()\` needs no provider. On the server, wrap the
30
+ * components that call it in \`<${naming.pascal(name)}StoreProvider>\` so they share one store per
31
+ * render; without it each one renders the initial state.
32
+ */
28
33
  export const [use${naming.pascal(name)}Store, ${naming.pascal(name)}StoreProvider, ${naming.camel(name)}Store] = createKitrStore<${naming.pascal(name)}State>(
29
34
  { name: '${naming.snake(name)}', persist: false },
30
35
  (set) => ({
@@ -33,7 +38,11 @@ export const [use${naming.pascal(name)}Store, ${naming.pascal(name)}StoreProvide
33
38
  reset: () => set({ count: 0 }),
34
39
  }),
35
40
  )
36
- `
41
+ `,
42
+ after(ctx) {
43
+ const keep = `${ctx.rc.directories.app ?? "app"}/stores/.gitkeep`;
44
+ if (ctx.exists(keep)) ctx.unlink(keep);
45
+ }
37
46
  } },
38
47
  wraps: { zustand: "@kitr/state" },
39
48
  eject: { "@kitr/state": "zustand" },
package/docs/api.md CHANGED
@@ -26,6 +26,7 @@ Creates a store that is a singleton in the browser and per-provider on the serve
26
26
  | `name` | `string` | required | Used for devtools and as the storage key `kitr:<name>`. |
27
27
  | `persist` | `boolean \| PersistOptions` | `false` | Save to `localStorage`. An object is passed to Zustand's `persist` middleware; its `name` overrides the storage key. |
28
28
  | `devtools` | `boolean` | `true` in dev | Connect to Redux DevTools in development. |
29
+ | `strict` | `boolean` | `false` | Throw on the server when the hook runs outside a `StoreProvider`. |
29
30
 
30
31
  ### Returns
31
32
 
@@ -34,12 +35,12 @@ A tuple. Name the parts whatever suits your store.
34
35
  | Position | Value | Description |
35
36
  | --- | --- | --- |
36
37
  | 0 | `useStore()` / `useStore(selector)` | Hook. Without a selector it returns the whole state. |
37
- | 1 | `StoreProvider` | Component with `children` and optional `initialState: Partial<T>`. Required on the server. |
38
+ | 1 | `StoreProvider` | Component with `children` and optional `initialState: Partial<T>`. Shares one store per server render. |
38
39
  | 2 | `store` | The browser's `StoreApi<T>`, or `null` on the server. |
39
40
 
40
41
  ### Errors
41
42
 
42
- Calling the hook on the server without a surrounding `StoreProvider` throws:
43
+ Without a surrounding `StoreProvider`, the hook on the server reads a fresh store of the component's own, built from the initial state. With `strict: true` it throws instead:
43
44
 
44
45
  ```
45
46
  [@kitr/state] Store "cart" has no instance on the server. Wrap the tree in its <StoreProvider>.
package/docs/index.md CHANGED
@@ -93,13 +93,14 @@ export const [usePreferences, PreferencesProvider, preferencesStore] =
93
93
  | `persist.version` | `0` | The saved shape's version. Saved data with another version goes through `migrate`, or is dropped without one. |
94
94
  | `persist.migrate` | none | A function `(saved, version) => state` that upgrades old saved data. |
95
95
  | `devtools` | `true` | Connects to the Redux DevTools in development; `false` leaves it out. |
96
+ | `strict` | `false` | Throws on the server when a component reads the store outside its `StoreProvider`. |
96
97
 
97
98
  The second argument is a Zustand state creator: it gets `set`, `get` and the store, and returns the initial state with its actions. `createKitrStore` returns three things, in this order:
98
99
 
99
100
  | Value | Description |
100
101
  | --- | --- |
101
102
  | `useStore` | The hook. `useStore(selector)` returns a slice; `useStore()` returns the whole state. |
102
- | `StoreProvider` | A component that gives its subtree a store. Needed on the server; optional in the browser. Takes `initialState`. |
103
+ | `StoreProvider` | A component that gives its subtree a store. Shares one store per server render; optional in the browser. Takes `initialState`. |
103
104
  | `store` | The browser's store instance, for code outside components. `null` on the server. |
104
105
 
105
106
 
@@ -457,8 +458,13 @@ One server process renders pages for many users, so a module-level store would l
457
458
 
458
459
  - **In the browser**, the store is one module-level instance, read without a provider.
459
460
  - **On the server**, each `<StoreProvider>` creates a fresh store for its render, and storage is never touched.
461
+ - **On the server without a provider**, each component that reads the store gets its own fresh store, built from the initial state and dropped with the render.
460
462
 
461
- If a page renders on the server (`ssr` or `static` mode), wrap the part of the tree that reads the store:
463
+ So a page renders on the server with or without a provider, and no state outlives the request. Without a provider nothing is shared either: a write during the server render reaches only the component that made it. Effects and event handlers run in the browser, where the one store is shared as usual.
464
+
465
+ ### Static and SSR
466
+
467
+ To share one store across a server render, for example to seed it with `initialState`, wrap the part of the tree that reads it:
462
468
 
463
469
  ```tsx
464
470
  // app/document.tsx
@@ -482,7 +488,9 @@ export function RootDocument() {
482
488
  }
483
489
  ```
484
490
 
485
- Without a provider, the server render fails with:
491
+ In a client-only app (`spa` mode, desktop, mobile) skip the provider.
492
+
493
+ To make a missing provider an error instead, pass `strict: true`. The server render then fails with:
486
494
 
487
495
  ```txt
488
496
  [@kitr/state] Store "order-draft" has no instance on the server. Wrap the tree in its <StoreProvider>.
@@ -490,6 +498,10 @@ Without a provider, the server render fails with:
490
498
 
491
499
  In a client-only app (`spa` mode, desktop, mobile) skip the provider.
492
500
 
501
+ ### Static and SSR
502
+
503
+ A `static` site is rendered on the server too: `kitr build` prerenders every page there, so the same rules apply as in an `ssr` app. Without a provider each component that reads the store gets its own fresh copy of the initial state for that render, which is fine for a widget that owns its state and wrong for two components that are meant to share it. To share, wrap them in one line in `app/document.tsx`, `<OrderDraftProvider><Outlet /></OrderDraftProvider>`; it is harmless in the browser, where the provider is optional. Set `strict: true` on the store to get an error instead of the fallback.
504
+
493
505
  <details>
494
506
  <summary>Persisted stores and hydration</summary>
495
507
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitr/state",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "@kitr/state: Zustand re-exported, plus an SSR-safe store factory.",
5
5
  "kitr": {
6
6
  "name": "state",
@@ -44,7 +44,7 @@
44
44
  "docs"
45
45
  ],
46
46
  "dependencies": {
47
- "@kitr/core": "0.3.1"
47
+ "@kitr/core": "0.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": ">=18.0.0",