@pylonsync/react 0.3.50 → 0.3.51

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.
Files changed (3) hide show
  1. package/package.json +3 -3
  2. package/src/db.ts +29 -2
  3. package/src/hooks.ts +107 -3
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.50",
6
+ "version": "0.3.51",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -12,8 +12,8 @@
12
12
  "check": "tsc -p tsconfig.json --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@pylonsync/sdk": "0.3.50",
16
- "@pylonsync/sync": "0.3.50"
15
+ "@pylonsync/sdk": "0.3.51",
16
+ "@pylonsync/sync": "0.3.51"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0"
package/src/db.ts CHANGED
@@ -106,11 +106,38 @@ export const db = {
106
106
  * const placeBid = db.useMutation<{lotId: string}, {accepted: boolean}>("placeBid");
107
107
  * await placeBid.mutate({ lotId: "x", amount: 150 });
108
108
  * ```
109
+ *
110
+ * For optimistic UI, pass an `optimistic` builder — the framework
111
+ * paints the row into the local store immediately, threads a
112
+ * matching id through to the server function, and reconciles the
113
+ * canonical broadcast as an in-place merge. See
114
+ * docs/concepts/optimistic-updates for the full pattern.
115
+ *
116
+ * ```tsx
117
+ * const send = db.useMutation<{channelId: string; body: string}, {messageId: string}>(
118
+ * "sendMessage",
119
+ * {
120
+ * optimistic: (args, ctx) => ({
121
+ * entity: "Message",
122
+ * data: { id: ctx.id, ...args, authorId: me.id, createdAt: ctx.now },
123
+ * }),
124
+ * }
125
+ * );
126
+ * ```
109
127
  */
110
128
  useMutation<TArgs = Record<string, unknown>, TResult = unknown>(
111
- fnName: string
129
+ fnName: string,
130
+ options: { optimistic?: import("./hooks").OptimisticBuilder<TArgs> } = {}
112
131
  ): UseMutationReturn<TArgs, TResult> {
113
- return useMutationHook<TArgs, TResult>(fnName);
132
+ // db.useMutation is the "I'm using the global sync engine" path —
133
+ // surface getSync() to the underlying hook so the optimistic
134
+ // ghost gets painted into the right store. Apps reaching for the
135
+ // raw `useMutation(fnName, { optimistic, sync })` for a non-global
136
+ // engine still work; this is just the ergonomic default.
137
+ return useMutationHook<TArgs, TResult>(fnName, {
138
+ optimistic: options.optimistic,
139
+ sync: getSync(),
140
+ });
114
141
  },
115
142
 
116
143
  /** Paginated live query with loadMore(). */
package/src/hooks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { SyncEngine, type Row } from "@pylonsync/sync";
3
+ import { SyncEngine, generateId, type Row } from "@pylonsync/sync";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { callFn, getBaseUrl, getReactStorage, storageKey } from "./index";
6
6
 
@@ -274,6 +274,61 @@ export interface UseMutationReturn<TArgs, TResult> {
274
274
  reset: () => void;
275
275
  }
276
276
 
277
+ /**
278
+ * Builder for the optimistic ghost row painted in the local store
279
+ * before the server function returns. Receives the args passed to
280
+ * `mutate()` plus a `ctx` object the framework fills in for you:
281
+ *
282
+ * - `ctx.id` — the freshly-minted Pylon-shaped row id (40-char hex)
283
+ * that the framework also threads into the mutation
284
+ * args as `_optimisticId`. Use this as the row's `id`
285
+ * so the optimistic ghost and the canonical broadcast
286
+ * share the same `row_id` and the WS update is an
287
+ * in-place merge instead of a delete-then-replace flash.
288
+ * - `ctx.now` — `new Date().toISOString()` evaluated once, so the
289
+ * optimistic ghost has a `createdAt` that's stable
290
+ * across the same gesture.
291
+ *
292
+ * Return either a single `{ entity, data }` for the common one-row
293
+ * case or an array for mutations that touch multiple entities (e.g.
294
+ * an "accept invite" that inserts a Membership AND an AuditLog row).
295
+ */
296
+ export interface OptimisticContext {
297
+ id: string;
298
+ now: string;
299
+ }
300
+ export type OptimisticChange = { entity: string; data: Row };
301
+ export type OptimisticBuilder<TArgs> = (
302
+ args: TArgs,
303
+ ctx: OptimisticContext,
304
+ ) => OptimisticChange | OptimisticChange[];
305
+
306
+ export interface UseMutationOptions<TArgs> {
307
+ token?: string;
308
+ /**
309
+ * Paint a row into the local store immediately, before the server
310
+ * function returns. The row uses `ctx.id` as its `id` and the
311
+ * framework threads that id through the mutation args as
312
+ * `_optimisticId` — your server function should accept it and pass
313
+ * it on to `ctx.db.insert("Entity", { id: args._optimisticId, ... })`
314
+ * (the runtime honors caller-supplied ids for any 40-char hex value).
315
+ *
316
+ * The WS broadcast that follows will carry the same `row_id`, so the
317
+ * canonical row lands as a field-level merge on top of the
318
+ * optimistic ghost — no flash, no temp-row swap, no manual cleanup.
319
+ *
320
+ * On rejection, the optimistic insert is rolled back without leaving
321
+ * a tombstone, so retrying the mutation works.
322
+ */
323
+ optimistic?: OptimisticBuilder<TArgs>;
324
+ /**
325
+ * Active sync engine. Required when `optimistic` is set so the hook
326
+ * can paint the ghost into the right store; ignored otherwise. The
327
+ * `db.useMutation` wrapper supplies this automatically via `getSync`.
328
+ */
329
+ sync?: SyncEngine;
330
+ }
331
+
277
332
  /**
278
333
  * Hook for calling a server-side mutation/action function.
279
334
  *
@@ -287,16 +342,27 @@ export interface UseMutationReturn<TArgs, TResult> {
287
342
  * if (result.accepted) alert("Bid placed!");
288
343
  * };
289
344
  * ```
345
+ *
346
+ * For optimistic UI, pass an `optimistic` builder. See
347
+ * `OptimisticBuilder` above for the contract.
290
348
  */
291
349
  export function useMutation<TArgs = Record<string, unknown>, TResult = unknown>(
292
350
  fnName: string,
293
- options: { token?: string } = {}
351
+ options: UseMutationOptions<TArgs> = {}
294
352
  ): UseMutationReturn<TArgs, TResult> {
295
353
  const [loading, setLoading] = useState(false);
296
354
  const [data, setData] = useState<TResult | null>(null);
297
355
  const [error, setError] = useState<Error | null>(null);
298
356
  const tokenRef = useRef(options.token);
299
357
  tokenRef.current = options.token;
358
+ // Stash the optimistic builder + sync handle in refs so changes
359
+ // between renders don't blow away in-flight mutations. The mutate
360
+ // closure reads through the ref so every call sees the latest
361
+ // builder without needing to re-bind the callback.
362
+ const optimisticRef = useRef(options.optimistic);
363
+ optimisticRef.current = options.optimistic;
364
+ const syncRef = useRef(options.sync);
365
+ syncRef.current = options.sync;
300
366
 
301
367
  // mounted guard: a mutate() kicked off right before unmount used to
302
368
  // resolve after cleanup and call set{Data,Error,Loading} on a dead
@@ -314,15 +380,53 @@ export function useMutation<TArgs = Record<string, unknown>, TResult = unknown>(
314
380
  async (args: TArgs): Promise<TResult> => {
315
381
  if (mounted.current) setLoading(true);
316
382
  if (mounted.current) setError(null);
383
+
384
+ // Paint the optimistic ghost(s) before kicking off the server
385
+ // call. The id we mint here goes into both the ghost and the
386
+ // mutation args (as `_optimisticId`) so the canonical row that
387
+ // arrives over the WS shares the same `row_id` — the local
388
+ // store treats the broadcast as an in-place merge rather than a
389
+ // new row, and the UI doesn't flash through "ghost → empty →
390
+ // canonical" while we wait for the server response.
391
+ let serverArgs = args as TArgs & { _optimisticId?: string };
392
+ let optimisticIds: Array<{ entity: string; id: string }> = [];
393
+ const sync = syncRef.current;
394
+ const builder = optimisticRef.current;
395
+ if (builder && sync) {
396
+ const id = generateId();
397
+ const now = new Date().toISOString();
398
+ const out = builder(args, { id, now });
399
+ const changes = Array.isArray(out) ? out : [out];
400
+ for (const change of changes) {
401
+ // Force the row's `id` to the framework-minted one even if
402
+ // the builder forgot — the ghost MUST share the row id with
403
+ // the canonical for the merge to land cleanly.
404
+ sync.store.optimisticInsertWithId(change.entity, id, {
405
+ ...change.data,
406
+ id,
407
+ });
408
+ optimisticIds.push({ entity: change.entity, id });
409
+ }
410
+ serverArgs = { ...args, _optimisticId: id };
411
+ }
412
+
317
413
  try {
318
414
  const result = await callFn<TResult>(
319
415
  fnName,
320
- args as Record<string, unknown>,
416
+ serverArgs as Record<string, unknown>,
321
417
  { token: tokenRef.current }
322
418
  );
323
419
  if (mounted.current) setData(result);
324
420
  return result;
325
421
  } catch (e) {
422
+ // Roll back optimistic ghosts without leaving a tombstone — a
423
+ // retry of the same mutation must not be blocked by a dead
424
+ // tombstone seq from this rejected attempt.
425
+ if (sync) {
426
+ for (const { entity, id } of optimisticIds) {
427
+ sync.store.rollbackOptimisticInsert(entity, id);
428
+ }
429
+ }
326
430
  const err = e instanceof Error ? e : new Error(String(e));
327
431
  if (mounted.current) setError(err);
328
432
  throw err;