@syncular/client-react 0.0.6-171 → 0.0.6-178

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.
@@ -40,6 +40,7 @@ import {
40
40
  createQueryContext,
41
41
  enqueueOutboxCommit,
42
42
  FingerprintCollector,
43
+ type FingerprintMode,
43
44
  type OutboxStats,
44
45
  type PresenceEntry,
45
46
  type QueryContext,
@@ -70,6 +71,125 @@ function isRecord(value: unknown): value is Record<string, unknown> {
70
71
  return typeof value === 'object' && value !== null && !Array.isArray(value);
71
72
  }
72
73
 
74
+ function shallowEqualRecords(
75
+ left: Record<string, unknown>,
76
+ right: Record<string, unknown>
77
+ ): boolean {
78
+ if (left === right) return true;
79
+
80
+ const leftKeys = Object.keys(left);
81
+ const rightKeys = Object.keys(right);
82
+ if (leftKeys.length !== rightKeys.length) return false;
83
+
84
+ for (const key of leftKeys) {
85
+ if (!(key in right)) return false;
86
+ if (!Object.is(left[key], right[key])) return false;
87
+ }
88
+
89
+ return true;
90
+ }
91
+
92
+ function shallowEqualQueryValues(left: unknown, right: unknown): boolean {
93
+ if (Object.is(left, right)) return true;
94
+ if (!isRecord(left) || !isRecord(right)) return false;
95
+ return shallowEqualRecords(left, right);
96
+ }
97
+
98
+ function getKeyedQueryValueKey<T>(value: T, keyField: string): string | null {
99
+ if (!isRecord(value) || !(keyField in value)) return null;
100
+
101
+ const key = value[keyField];
102
+ if (key === null || key === undefined) return null;
103
+ return String(key);
104
+ }
105
+
106
+ function shareArrayResult<T>(previous: T[], next: T[], keyField: string): T[] {
107
+ if (previous.length === 0 || next.length === 0) {
108
+ return next;
109
+ }
110
+
111
+ const previousByKey = new Map<string, T>();
112
+ let canShareByKey = true;
113
+
114
+ for (const item of previous) {
115
+ const key = getKeyedQueryValueKey(item, keyField);
116
+ if (key === null) {
117
+ canShareByKey = false;
118
+ break;
119
+ }
120
+ previousByKey.set(key, item);
121
+ }
122
+
123
+ const shared = next.slice();
124
+
125
+ if (canShareByKey) {
126
+ for (const [index, item] of next.entries()) {
127
+ const key = getKeyedQueryValueKey(item, keyField);
128
+ if (key === null) {
129
+ return next;
130
+ }
131
+
132
+ const previousItem = previousByKey.get(key);
133
+ if (
134
+ previousItem !== undefined &&
135
+ shallowEqualQueryValues(previousItem, item)
136
+ ) {
137
+ shared[index] = previousItem;
138
+ }
139
+ }
140
+ } else {
141
+ const limit = Math.min(previous.length, next.length);
142
+ for (let index = 0; index < limit; index += 1) {
143
+ const previousItem = previous[index];
144
+ const nextItem = next[index];
145
+ if (
146
+ previousItem !== undefined &&
147
+ nextItem !== undefined &&
148
+ shallowEqualQueryValues(previousItem, nextItem)
149
+ ) {
150
+ shared[index] = previousItem;
151
+ }
152
+ }
153
+ }
154
+
155
+ if (shared.length !== previous.length) {
156
+ return shared;
157
+ }
158
+
159
+ for (let index = 0; index < shared.length; index += 1) {
160
+ if (!Object.is(shared[index], previous[index])) {
161
+ return shared;
162
+ }
163
+ }
164
+
165
+ return previous;
166
+ }
167
+
168
+ function shareQueryResult<TResult>(
169
+ previous: TResult | undefined,
170
+ next: TResult,
171
+ keyField: string,
172
+ enabled: boolean
173
+ ): TResult {
174
+ if (!enabled || previous === undefined) {
175
+ return next;
176
+ }
177
+
178
+ if (Array.isArray(previous) && Array.isArray(next)) {
179
+ return shareArrayResult(previous, next, keyField) as TResult;
180
+ }
181
+
182
+ if (
183
+ isRecord(previous) &&
184
+ isRecord(next) &&
185
+ shallowEqualRecords(previous, next)
186
+ ) {
187
+ return previous;
188
+ }
189
+
190
+ return next;
191
+ }
192
+
73
193
  type ExecutableQuery<TResult> = {
74
194
  execute: () => Promise<TResult>;
75
195
  };
@@ -372,8 +492,21 @@ export interface UseSyncQueryOptions {
372
492
  deps?: unknown[];
373
493
  keyField?: string;
374
494
  watchTables?: string[];
495
+ /**
496
+ * Fingerprint strategy used to decide whether a refreshed query result should
497
+ * publish new data.
498
+ * - `auto` (default): row-based fingerprints for single-table keyed arrays,
499
+ * value-based fingerprints otherwise.
500
+ * - `value`: always fingerprint the full result value.
501
+ */
502
+ fingerprintMode?: FingerprintMode;
375
503
  pollIntervalMs?: number;
376
504
  staleAfterMs?: number;
505
+ /**
506
+ * Reuse unchanged object references between query refreshes.
507
+ * Enabled by default for safer list rendering.
508
+ */
509
+ structuralSharing?: boolean;
377
510
  /**
378
511
  * Internal: if false, skip `data:change` invalidation listener.
379
512
  * Used by `useQuery` wrapper.
@@ -1299,8 +1432,10 @@ export function createSyncularReact<
1299
1432
  deps = [],
1300
1433
  keyField = 'id',
1301
1434
  watchTables = [],
1435
+ fingerprintMode = 'auto',
1302
1436
  pollIntervalMs,
1303
1437
  staleAfterMs,
1438
+ structuralSharing = true,
1304
1439
  refreshOnDataChange = true,
1305
1440
  loadingOnRefresh = false,
1306
1441
  transitionUpdates = true,
@@ -1389,7 +1524,8 @@ export function createSyncularReact<
1389
1524
  scopeCollector,
1390
1525
  fingerprintCollectorRef.current,
1391
1526
  engine,
1392
- keyField
1527
+ keyField,
1528
+ fingerprintMode
1393
1529
  );
1394
1530
 
1395
1531
  const fnResult = queryFnRef.current(ctx);
@@ -1415,7 +1551,14 @@ export function createSyncularReact<
1415
1551
  () => {
1416
1552
  setLastSyncAt(snapshotLastSyncAt);
1417
1553
  if (didFingerprintChange) {
1418
- setData(result);
1554
+ setData((previous) =>
1555
+ shareQueryResult(
1556
+ previous,
1557
+ result,
1558
+ keyField,
1559
+ structuralSharing
1560
+ )
1561
+ );
1419
1562
  }
1420
1563
  setError(null);
1421
1564
  },
@@ -1449,8 +1592,10 @@ export function createSyncularReact<
1449
1592
  db,
1450
1593
  enabled,
1451
1594
  engine,
1595
+ fingerprintMode,
1452
1596
  keyField,
1453
1597
  loadingOnRefresh,
1598
+ structuralSharing,
1454
1599
  applyUpdate,
1455
1600
  emitMetrics,
1456
1601
  ]
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export type {
46
46
  UseTransportHealthResult,
47
47
  } from './createSyncularReact';
48
48
  // Re-export core client types for convenience.
49
- export { createSyncularReact } from './createSyncularReact';
49
+ export { createSyncularReact } from './createSyncularReact-public';
50
50
  export type { UseCachedAsyncValueOptions } from './use-cached-async-value';
51
51
  export {
52
52
  clearCachedAsyncValues,