@molecule/app-react 3.2.0 → 3.3.1

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
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-11-03T18:02:49.265Z
6
+ Generated: 2026-11-04T14:20:06.747Z
7
7
  -->
8
8
 
9
9
  # @molecule/app-react
@@ -170,165 +170,65 @@ Provides login/logout/register flows, token management, profile
170
170
  updates, and auth state subscription.
171
171
 
172
172
  ```typescript
173
- interface AuthClient<T = UserProfile, TRegister extends RegisterExtraShape<TRegister> = Record<never, never>> {
174
- /**
175
- * Returns the current authentication state snapshot.
176
- *
177
- * READING the state starts the client's one session restore when nobody has called
178
- * {@link initialize} yet — lazily and exactly once, sharing the promise an explicit
179
- * `initialize()` returns. A hook (`useAuth()`), a subscriber, `isAuthenticated()` and
180
- * `getUser()` are reads too, so a client whose entry file dropped the template's
181
- * `authClient.initialize()` line still restores instead of reporting `'loading'`
182
- * forever.
183
- */
184
- getState(): AuthState<T>;
185
- /**
186
- * Returns whether the user is currently authenticated.
187
- */
188
- isAuthenticated(): boolean;
189
- /**
190
- * Gets the current user.
191
- */
192
- getUser(): T | null;
193
- /**
194
- * Optional ownership marker for the current session. Compare values only within
195
- * this client: replacing or ending a session changes it, including re-adopting
196
- * the same token and user. Profile updates and same-session refreshes keep it.
197
- * Consumers can use it to discard stale async work without treating editable
198
- * user fields as an immutable account identifier. Providers without this
199
- * capability require the consumer's own stable identity check.
200
- */
201
- getSessionRevision?(): number;
202
- /**
203
- * Updates the cached user object (state + persistent storage) without
204
- * hitting the network. Intended for local refreshes after a per-app
205
- * mutation (e.g., the user just PATCHed their own profile and the
206
- * server returned the canonical row). A non-null profile does NOT change
207
- * tokens or sign the client in: `authenticated` and `status` are unchanged,
208
- * so an anonymous client stays anonymous. Use {@link login} /
209
- * {@link register} for a credential exchange, or {@link setSession} when
210
- * the token was issued outside the client.
211
- *
212
- * Passing `null` on a signed-in client, or while any session operation is
213
- * pending, ends the local session: it clears the access token, refresh token
214
- * and cached user, stops automatic refresh, and settles `authenticated: false`
215
- * with `status: 'unauthenticated'` in one notification. Every older operation
216
- * loses session ownership, and a persistent sign-out marker prevents a reload
217
- * from restoring its credentials or a late cookie. A late credential exchange
218
- * is revoked best-effort when a logout endpoint is configured, without blocking
219
- * the caller. This method does not otherwise call the server for a settled
220
- * session; use {@link logout} for explicit server revocation. On a settled
221
- * anonymous client, `null` only clears the cached profile and leaves tokens
222
- * and auth state unchanged.
223
- */
224
- setUser(user: T | null): void;
225
- /**
226
- * Gets the current access token.
227
- */
228
- getAccessToken(): string | null;
229
- /**
230
- * Stores the access token in the configured token storage adapter (in a
231
- * browser, Web Storage; `'memory'` where no browser store exists). Use this
232
- * to seed the token after an out-of-band exchange (e.g. the OAuth
233
- * code→token redirect) instead of writing to `localStorage` directly,
234
- * which would violate the storage contract and make a stored bearer token
235
- * JS-readable (XSS-exfiltratable). Pass `null` to clear it.
236
- *
237
- * This changes token storage only: passing `null` does not by itself sign a
238
- * signed-in client out, and a non-null token does not sign an anonymous
239
- * client in (`authenticated` and `status` are unchanged). Use {@link logout}
240
- * to end a session, or {@link setUser} with `null` for local-only sign-out.
241
- * Use {@link login} / {@link register} for a credential exchange, or call
242
- * {@link setSession} with the token and user to establish an already-issued
243
- * session.
244
- */
245
- setAccessToken(token: string | null): void;
246
- /**
247
- * Adopts a session an out-of-band exchange already established: stores the
248
- * access token (and the refresh token when given), caches the user, and
249
- * sets the COMPLETE state — `initialized`, `authenticated`, `status:
250
- * 'authenticated'`, no error — in one update, with one subscriber
251
- * notification. Every hook reading the shared client sees the user as
252
- * signed in immediately.
253
- *
254
- * This is the way to seed an OAuth/custom sign-in. Calling
255
- * {@link setAccessToken} and {@link setUser} instead does NOT sign the
256
- * client in.
257
- */
258
- setSession(session: AuthSessionSeed<T>): void;
259
- /**
260
- * Gets the refresh token.
261
- */
262
- getRefreshToken(): string | null;
263
- /**
264
- * Logs in with credentials.
265
- */
266
- login(credentials: LoginCredentials): Promise<AuthResult<T>>;
267
- /**
268
- * Logs out the current user. The server request is best-effort: a missing
269
- * logout route (404), any other HTTP failure, or a network error never makes
270
- * this method reject. It always clears the access token, refresh token and
271
- * cached user, stops automatic refresh, and publishes one settled
272
- * `authenticated: false`, `status: 'unauthenticated'` transition. Every pending
273
- * session operation loses ownership; a persistent marker prevents reload from
274
- * restoring an older session, and late credentials are revoked best-effort.
275
- *
276
- * When the API has no logout route, set {@link AuthClientConfigOptions.logoutEndpoint}
277
- * to `null` (or `''`) to skip the request. Local sign-out still happens.
278
- */
279
- logout(): Promise<void>;
280
- /**
281
- * Registers a new user. Accepts the base fields plus the app's own fields
282
- * (`TRegister`) at the top level — see {@link RegisterData}.
283
- */
284
- register(data: RegisterData<TRegister>): Promise<AuthResult<T>>;
285
- /**
286
- * Refreshes the access token.
287
- */
288
- refresh(): Promise<AuthResult<T>>;
289
- /**
290
- * Requests a password reset.
291
- */
292
- requestPasswordReset(data: PasswordResetRequest): Promise<void>;
293
- /**
294
- * Confirms a password reset.
295
- */
296
- confirmPasswordReset(data: PasswordResetConfirm): Promise<void>;
297
- /**
298
- * Updates the current user's profile.
299
- * Returns the saved profile: the answer applied onto the signed-in user field
300
- * by field, with or without an `id` — a field it carries replaces the value
301
- * (`null` clears it), one it omits or leaves `undefined` is kept, so a field
302
- * cleared by omission must arrive as `null`. A non-object answer is returned
303
- * as it came. An answer for a replaced or ended session leaves the current one
304
- * untouched.
305
- */
306
- updateProfile(data: Partial<T>): Promise<T>;
307
- /**
308
- * Changes the current user's password.
309
- */
310
- changePassword(oldPassword: string, newPassword: string): Promise<void>;
311
- /**
312
- * Initializes auth state (e.g., from stored tokens).
313
- *
314
- * LAZY and IDEMPOTENT: the first read of the state ({@link getState},
315
- * {@link subscribe}, `useAuth()`) starts this same restore when nobody has called
316
- * `initialize()`, and every later call shares the one promise instead of sending a
317
- * second request. An app that keeps the template's explicit call behaves exactly as
318
- * before; an app that loses that line still restores its session.
319
- *
320
- * With a stored token AND a stored user it marks the session `'authenticated'`
321
- * SYNCHRONOUSLY — before its first `await` — so the first render is the
322
- * signed-in UI and no page waits on a verification round trip. Any check
323
- * (a refresh, the cookie-restore probe) settles behind that render, and only an
324
- * explicit 401/403 ends the session; a network/5xx/404 failure keeps it and
325
- * reports `'unavailable'`. With no stored user the client stays `'loading'`
326
- * until the cookie restore decides.
327
- *
328
- * Do not put this call (or a `/me` request of your own) in front of the first
329
- // … truncated — the full declaration is in node_modules/@molecule/app-react/dist
173
+ interface AuthClient<
174
+ T = UserProfile,
175
+ TRegister extends RegisterExtraShape<TRegister> = Record<never, never>,
176
+ > {
177
+ /** Returns the current authentication state snapshot. */
178
+ getState(): AuthState<T>
179
+ /** Returns whether the user is currently authenticated. */
180
+ isAuthenticated(): boolean
181
+ /** Gets the current user. */
182
+ getUser(): T | null
183
+ /** Optional ownership marker for the current session. */
184
+ getSessionRevision?(): number
185
+ /** Updates the cached user object (state + persistent storage) without hitting the network. */
186
+ setUser(user: T | null): void
187
+ /** Gets the current access token. */
188
+ getAccessToken(): string | null
189
+ /** Stores the access token in the configured token storage adapter (in a browser, Web Storage; `'memory'` where no browser store exists). */
190
+ setAccessToken(token: string | null): void
191
+ /** Adopts a session an out-of-band exchange already established: stores the access token (and the refresh token when given), caches the user, and sets the COMPLET… */
192
+ setSession(session: AuthSessionSeed<T>): void
193
+ /** Gets the refresh token. */
194
+ getRefreshToken(): string | null
195
+ /** Logs in with credentials. */
196
+ login(credentials: LoginCredentials): Promise<AuthResult<T>>
197
+ /** Logs out the current user. */
198
+ logout(): Promise<void>
199
+ /** Registers a new user. */
200
+ register(data: RegisterData<TRegister>): Promise<AuthResult<T>>
201
+ /** Refreshes the access token. */
202
+ refresh(): Promise<AuthResult<T>>
203
+ /** Requests a password reset. */
204
+ requestPasswordReset(data: PasswordResetRequest): Promise<void>
205
+ /** Confirms a password reset. */
206
+ confirmPasswordReset(data: PasswordResetConfirm): Promise<void>
207
+ /** Updates the current user's profile. */
208
+ updateProfile(data: Partial<T>): Promise<T>
209
+ /** Changes the current user's password. */
210
+ changePassword(oldPassword: string, newPassword: string): Promise<void>
211
+ /** Initializes auth state (e.g., from stored tokens). */
212
+ initialize(): Promise<void>
213
+ /** Re-checks a restored session against the server, in the background — the boot-time validation an app with a stored token needs when a revoked or expired token… */
214
+ validateSession?(): Promise<void>
215
+ /** Resolves when the session restore started by `initialize()` has settled — the point after which the client knows whether this visitor is signed in. */
216
+ whenRestored?(): Promise<void>
217
+ /** Subscribes to auth state changes. */
218
+ subscribe(callback: (state: AuthState<T>) => void): () => void
219
+ /** Subscribes to auth state changes (alias for subscribe). */
220
+ onAuthChange(callback: (state: AuthState<T>) => void): () => void
221
+ /** Gets the current access token (alias for getAccessToken). */
222
+ getToken?(): string | null
223
+ /** Adds an auth event listener. */
224
+ addEventListener(listener: AuthEventListener<T>): () => void
225
+ /** Destroys the auth client. */
226
+ destroy(): void
227
+ }
330
228
  ```
331
229
 
230
+ _(Member comments are shortened to their first sentence to fit; the full declaration is in `node_modules/@molecule/app-react/dist`.)_
231
+
332
232
  #### `FormController`
333
233
 
334
234
  Form controller interface.
@@ -1029,6 +929,28 @@ interface UseOAuthReturn {
1029
929
  }
1030
930
  ```
1031
931
 
932
+ #### `UseOptimisticValueResult`
933
+
934
+ What {@link useOptimisticValue} returns: the state to render and the two ways to change it.
935
+
936
+ ```typescript
937
+ interface UseOptimisticValueResult<T> extends OptimisticValueState<T> {
938
+ /**
939
+ * Shows `next` at once and saves it, after any save already running.
940
+ *
941
+ * @param next - The value the person chose.
942
+ */
943
+ set: (next: T) => void
944
+
945
+ /**
946
+ * Like `set`, computed from the value shown now (`update((on) => !on)`).
947
+ *
948
+ * @param change - Computes the next value from the value shown now.
949
+ */
950
+ update: (change: (current: T) => T) => void
951
+ }
952
+ ```
953
+
1032
954
  #### `UsePasswordResetReturn`
1033
955
 
1034
956
  Return type for usePasswordReset hook.
@@ -2505,6 +2427,27 @@ function useOAuth(config?: {
2505
2427
 
2506
2428
  **Returns:** OAuth helpers: providers, getOAuthUrl, redirect, and loginViaPopup.
2507
2429
 
2430
+ #### `useOptimisticValue(options)`
2431
+
2432
+ A server-backed value — a favorite, follow, like or bookmark button, a rating, a quantity, a
2433
+ setting switch — that changes on the first render after a tap and is saved one change at a
2434
+ time, in order. A tap during a save is saved right after it; only a failure of the newest
2435
+ change puts the server value back. See `createOptimisticValue` in `@molecule/app-state`.
2436
+
2437
+ `value` is the server's value from props or a query. A new `value` is taken only when it
2438
+ differs (by `equals`) from the one the hook last received, and only while nothing is being
2439
+ saved, so a parent re-render or a refetch of older data never flips the button mid-save. The
2440
+ latest `commit` and `onError` are always used. Give the component a `key` per record, so one
2441
+ record's pending change never shows on another.
2442
+
2443
+ ```typescript
2444
+ function useOptimisticValue<T>(options: OptimisticValueOptions<T>): UseOptimisticValueResult<T>
2445
+ ```
2446
+
2447
+ - `options` — The server's current value and how to save a change.
2448
+
2449
+ **Returns:** The value to render, whether a save is pending, the newest failure, and `set`/`update`.
2450
+
2508
2451
  #### `usePasswordReset()`
2509
2452
 
2510
2453
  Hook for password reset flow with async state tracking.
@@ -3235,7 +3178,7 @@ as `'unavailable'`. Custom clients must also settle their initial session status
3235
3178
  Synchronous SSR renders the fallback while a client is loading. Initialize a request-scoped
3236
3179
  client before rendering to include its decided content, or leave the hold disabled for SSR.
3237
3180
 
3238
- _(description truncated — read the full text in `node_modules/@molecule/app-react/dist/session-ready.d.ts`.)_
3181
+ Route-level redirects for signed-in-only pages stay with `AuthGuard` (`@molecule/app-ui-react`).
3239
3182
 
3240
3183
  ```typescript
3241
3184
  function SessionReady({ children, fallback = null }: SessionReadyProps): ReactNode
@@ -3383,7 +3326,7 @@ Peer dependencies:
3383
3326
  - `@molecule/app-i18n` ^1.0.1
3384
3327
  - `@molecule/app-logger` ^1.0.1
3385
3328
  - `@molecule/app-routing` ^1.0.1
3386
- - `@molecule/app-state` ^1.0.1
3329
+ - `@molecule/app-state` ^1.1.0
3387
3330
  - `@molecule/app-storage` ^1.0.1
3388
3331
  - `@molecule/app-theme` ^1.0.1
3389
3332
  - `@molecule/app-ui` ^1.0.1
@@ -16,6 +16,7 @@ export * from './useHttp.js';
16
16
  export * from './useLogger.js';
17
17
  export * from './useLogin.js';
18
18
  export * from './useOAuth.js';
19
+ export * from './useOptimisticValue.js';
19
20
  export * from './usePasswordReset.js';
20
21
  export * from './usePlatform.js';
21
22
  export * from './usePreview.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,cAAc,CAAA;AAC5B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,qBAAqB,CAAA;AACnC,cAAc,6BAA6B,CAAA;AAC3C,cAAc,iBAAiB,CAAA;AAC/B,cAAc,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,cAAc,CAAA;AAC5B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,yBAAyB,CAAA;AACvC,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,qBAAqB,CAAA;AACnC,cAAc,6BAA6B,CAAA;AAC3C,cAAc,iBAAiB,CAAA;AAC/B,cAAc,mBAAmB,CAAA"}
@@ -16,6 +16,7 @@ export * from './useHttp.js';
16
16
  export * from './useLogger.js';
17
17
  export * from './useLogin.js';
18
18
  export * from './useOAuth.js';
19
+ export * from './useOptimisticValue.js';
19
20
  export * from './usePasswordReset.js';
20
21
  export * from './usePlatform.js';
21
22
  export * from './usePreview.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,cAAc,CAAA;AAC5B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,qBAAqB,CAAA;AACnC,cAAc,6BAA6B,CAAA;AAC3C,cAAc,iBAAiB,CAAA;AAC/B,cAAc,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,cAAc,CAAA;AAC5B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,yBAAyB,CAAA;AACvC,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,qBAAqB,CAAA;AACnC,cAAc,6BAA6B,CAAA;AAC3C,cAAc,iBAAiB,CAAA;AAC/B,cAAc,mBAAmB,CAAA"}
@@ -0,0 +1,67 @@
1
+ import type { OptimisticValueOptions, OptimisticValueState } from '@molecule/app-state';
2
+ /**
3
+ * What {@link useOptimisticValue} returns: the state to render and the two ways to change it.
4
+ */
5
+ export interface UseOptimisticValueResult<T> extends OptimisticValueState<T> {
6
+ /**
7
+ * Shows `next` at once and saves it, after any save already running.
8
+ *
9
+ * @param next - The value the person chose.
10
+ */
11
+ set: (next: T) => void;
12
+ /**
13
+ * Like `set`, computed from the value shown now (`update((on) => !on)`).
14
+ *
15
+ * @param change - Computes the next value from the value shown now.
16
+ */
17
+ update: (change: (current: T) => T) => void;
18
+ }
19
+ /**
20
+ * A server-backed value — a favorite, follow, like or bookmark button, a rating, a quantity, a
21
+ * setting switch — that changes on the first render after a tap and is saved one change at a
22
+ * time, in order. A tap during a save is saved right after it; only a failure of the newest
23
+ * change puts the server value back. See `createOptimisticValue` in `@molecule/app-state`.
24
+ *
25
+ * `value` is the server's value from props or a query. A new `value` is taken only when it
26
+ * differs (by `equals`) from the one the hook last received, and only while nothing is being
27
+ * saved, so a parent re-render or a refetch of older data never flips the button mid-save. The
28
+ * latest `commit` and `onError` are always used. Give the component a `key` per record, so one
29
+ * record's pending change never shows on another.
30
+ *
31
+ * @param options - The server's current value and how to save a change.
32
+ * @returns The value to render, whether a save is pending, the newest failure, and `set`/`update`.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * import { useOptimisticValue } from '@molecule/app-react'
37
+ *
38
+ * function StarButton({ repository }: { repository: Repository }) {
39
+ * const star = useOptimisticValue({
40
+ * value: { starred: repository.starred, stars: repository.stars },
41
+ * equals: (a, b) => a.starred === b.starred && a.stars === b.stars,
42
+ * commit: async (next) => {
43
+ * const response = next.starred
44
+ * ? await http.put(`/repositories/${repository.id}/star`)
45
+ * : await http.delete(`/repositories/${repository.id}/star`)
46
+ * return { starred: response.data.starred, stars: response.data.stars }
47
+ * },
48
+ * })
49
+ * // Never disable the button or return early while `star.pending` is true.
50
+ * return (
51
+ * <button
52
+ * aria-pressed={star.value.starred}
53
+ * onClick={() =>
54
+ * star.update((current) => ({
55
+ * starred: !current.starred,
56
+ * stars: current.stars + (current.starred ? -1 : 1),
57
+ * }))
58
+ * }
59
+ * >
60
+ * {star.value.stars}
61
+ * </button>
62
+ * )
63
+ * }
64
+ * ```
65
+ */
66
+ export declare const useOptimisticValue: <T>(options: OptimisticValueOptions<T>) => UseOptimisticValueResult<T>;
67
+ //# sourceMappingURL=useOptimisticValue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useOptimisticValue.d.ts","sourceRoot":"","sources":["../../src/hooks/useOptimisticValue.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAGvF;;GAEG;AACH,MAAM,WAAW,wBAAwB,CAAC,CAAC,CAAE,SAAQ,oBAAoB,CAAC,CAAC,CAAC;IAC1E;;;;OAIG;IACH,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;IAEtB;;;;OAIG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,KAAK,IAAI,CAAA;CAC5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,eAAO,MAAM,kBAAkB,GAAI,CAAC,EAClC,SAAS,sBAAsB,CAAC,CAAC,CAAC,KACjC,wBAAwB,CAAC,CAAC,CA2B5B,CAAA"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * React hook for a server-backed value that the UI changes at once and saves in order.
3
+ *
4
+ * @module
5
+ */
6
+ import { useEffect, useInsertionEffect, useRef, useState, useSyncExternalStore } from 'react';
7
+ import { createOptimisticValue, shallowEqual } from '@molecule/app-state';
8
+ /**
9
+ * A server-backed value — a favorite, follow, like or bookmark button, a rating, a quantity, a
10
+ * setting switch — that changes on the first render after a tap and is saved one change at a
11
+ * time, in order. A tap during a save is saved right after it; only a failure of the newest
12
+ * change puts the server value back. See `createOptimisticValue` in `@molecule/app-state`.
13
+ *
14
+ * `value` is the server's value from props or a query. A new `value` is taken only when it
15
+ * differs (by `equals`) from the one the hook last received, and only while nothing is being
16
+ * saved, so a parent re-render or a refetch of older data never flips the button mid-save. The
17
+ * latest `commit` and `onError` are always used. Give the component a `key` per record, so one
18
+ * record's pending change never shows on another.
19
+ *
20
+ * @param options - The server's current value and how to save a change.
21
+ * @returns The value to render, whether a save is pending, the newest failure, and `set`/`update`.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * import { useOptimisticValue } from '@molecule/app-react'
26
+ *
27
+ * function StarButton({ repository }: { repository: Repository }) {
28
+ * const star = useOptimisticValue({
29
+ * value: { starred: repository.starred, stars: repository.stars },
30
+ * equals: (a, b) => a.starred === b.starred && a.stars === b.stars,
31
+ * commit: async (next) => {
32
+ * const response = next.starred
33
+ * ? await http.put(`/repositories/${repository.id}/star`)
34
+ * : await http.delete(`/repositories/${repository.id}/star`)
35
+ * return { starred: response.data.starred, stars: response.data.stars }
36
+ * },
37
+ * })
38
+ * // Never disable the button or return early while `star.pending` is true.
39
+ * return (
40
+ * <button
41
+ * aria-pressed={star.value.starred}
42
+ * onClick={() =>
43
+ * star.update((current) => ({
44
+ * starred: !current.starred,
45
+ * stars: current.stars + (current.starred ? -1 : 1),
46
+ * }))
47
+ * }
48
+ * >
49
+ * {star.value.stars}
50
+ * </button>
51
+ * )
52
+ * }
53
+ * ```
54
+ */
55
+ export const useOptimisticValue = (options) => {
56
+ const latest = useRef(options);
57
+ // Exposed actions may run in a descendant's layout effect, before this component's
58
+ // layout/passive effects. Publish options from committed renders before those actions.
59
+ useInsertionEffect(() => {
60
+ latest.current = options;
61
+ });
62
+ const [controller] = useState(() => createOptimisticValue({
63
+ value: options.value,
64
+ commit: (next, previous) => latest.current.commit(next, previous),
65
+ equals: (a, b) => (latest.current.equals ?? shallowEqual)(a, b),
66
+ onError: (error, value) => latest.current.onError?.(error, value),
67
+ }));
68
+ const received = useRef(options.value);
69
+ const { value, equals } = options;
70
+ useEffect(() => {
71
+ if ((equals ?? shallowEqual)(received.current, value))
72
+ return;
73
+ received.current = value;
74
+ controller.sync(value);
75
+ }, [controller, value, equals]);
76
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
77
+ return { ...state, set: controller.set, update: controller.update };
78
+ };
79
+ //# sourceMappingURL=useOptimisticValue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useOptimisticValue.js","sourceRoot":"","sources":["../../src/hooks/useOptimisticValue.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AAG7F,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAqBzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAChC,OAAkC,EACL,EAAE;IAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,CAAA;IAC9B,mFAAmF;IACnF,uFAAuF;IACvF,kBAAkB,CAAC,GAAG,EAAE;QACtB,MAAM,CAAC,OAAO,GAAG,OAAO,CAAA;IAC1B,CAAC,CAAC,CAAA;IAEF,MAAM,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CACjC,qBAAqB,CAAI;QACvB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,MAAM,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC;QACjE,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,IAAI,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC/D,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC;KAClE,CAAC,CACH,CAAA;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;IACtC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IACjC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,MAAM,IAAI,YAAY,CAAC,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;YAAE,OAAM;QAC7D,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;QACxB,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACxB,CAAC,EAAE,CAAC,UAAU,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;IAE/B,MAAM,KAAK,GAAG,oBAAoB,CAAC,UAAU,CAAC,SAAS,EAAE,UAAU,CAAC,QAAQ,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAA;IAClG,OAAO,EAAE,GAAG,KAAK,EAAE,GAAG,EAAE,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAA;AACrE,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/app-react",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "React framework bindings for molecule.dev",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "@molecule/app-i18n": "^1.0.1",
32
32
  "@molecule/app-logger": "^1.0.1",
33
33
  "@molecule/app-routing": "^1.0.1",
34
- "@molecule/app-state": "^1.0.1",
34
+ "@molecule/app-state": "^1.1.0",
35
35
  "@molecule/app-storage": "^1.0.1",
36
36
  "@molecule/app-theme": "^1.0.1",
37
37
  "@molecule/app-ui": "^1.0.1",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
- "@molecule/app-ai-models": "1.4.3",
64
- "@molecule/app-bonds-default-react": "3.0.1",
63
+ "@molecule/app-ai-models": "1.4.4",
64
+ "@molecule/app-bonds-default-react": "3.0.2",
65
65
  "@molecule/app-i18n": "1.1.2",
66
66
  "@testing-library/dom": "10.4.2",
67
67
  "@testing-library/react": "16.3.3",