@pylonsync/react 0.3.49 → 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.
- package/package.json +3 -3
- package/src/db.ts +29 -2
- 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.
|
|
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.
|
|
16
|
-
"@pylonsync/sync": "0.3.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|