@pylonsync/react 0.3.359 → 0.3.360

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 CHANGED
@@ -3,24 +3,29 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.359",
6
+ "version": "0.3.360",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.build.json",
12
12
  "check": "tsc -p tsconfig.json --noEmit",
13
+ "test": "bun test",
13
14
  "prepack": "bun run build"
14
15
  },
15
16
  "dependencies": {
16
- "@pylonsync/sdk": "0.3.359",
17
- "@pylonsync/sync": "0.3.359"
17
+ "@pylonsync/sdk": "0.3.360",
18
+ "@pylonsync/sync": "0.3.360"
18
19
  },
19
20
  "peerDependencies": {
20
21
  "react": ">=19.0.0"
21
22
  },
22
23
  "devDependencies": {
23
- "@types/react": "^19.0.0"
24
+ "@happy-dom/global-registrator": "^20.10.0",
25
+ "@testing-library/react": "^16.3.0",
26
+ "@types/react": "^19.0.0",
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0"
24
29
  },
25
30
  "files": [
26
31
  "src",
package/src/hooks.ts CHANGED
@@ -50,6 +50,13 @@ export interface UseQueryOneReturn<T> {
50
50
  // stable reference.
51
51
  const EMPTY_SNAPSHOT: readonly unknown[] = [];
52
52
 
53
+ // Server snapshot for the settled signal. On the server there is no engine to
54
+ // have settled, and rendering "not settled" is what makes SSR emit the skeleton
55
+ // the client then replaces — matching what a cold client sees, so hydration
56
+ // doesn't swap the tree. Hoisted for the same reason as EMPTY_SNAPSHOT: a
57
+ // stable identity, though a bare boolean would compare fine.
58
+ const FALSE_SNAPSHOT = (): boolean => false;
59
+
53
60
  // useQuery — high-level hook returning {data, loading, error}
54
61
  // ---------------------------------------------------------------------------
55
62
 
@@ -80,19 +87,12 @@ export function useQuery<T = Row>(
80
87
  entity: string,
81
88
  options?: QueryOptions
82
89
  ): UseQueryReturn<T> {
83
- // `loading` is true only while we genuinely don't know yet — i.e., the
84
- // engine doesn't yet have a SERVER-confirmed view. It gates on
85
- // `isInitialSyncSettled()`, NOT `isHydrated()`: the latter flips true the
86
- // instant IndexedDB loads, which on a cold/empty cache (first visit, or
87
- // right after an org switch wipes the replica) is immediate and EMPTY — so
88
- // gating on it drops `loading` while the rows are still en route from the
89
- // server, flashing the empty state for the seconds until the snapshot lands.
90
- // `isInitialSyncSettled()` stays pending until the first pull settles (or
91
- // the cache already had rows), so callers render a skeleton instead.
92
- const loading = useRef<boolean>(
93
- !sync.isInitialSyncSettled() && sync.store.list(entity).length === 0,
94
- );
95
- const error = useRef<Error | null>(null);
90
+ // Both are state, not refs. A ref mutated inside an async callback changes
91
+ // nothing React can see: the old `error` ref was assigned in `pull()`'s catch
92
+ // and never re-rendered, so a failed refetch surfaced no error until some
93
+ // unrelated update happened to re-render the component.
94
+ const [error, setError] = useState<Error | null>(null);
95
+ const [refetching, setRefetching] = useState(false);
96
96
  const optionsKey = JSON.stringify(options || {});
97
97
 
98
98
  // Subscribe function stable across the lifetime of this entity/options combo.
@@ -121,15 +121,6 @@ export function useQuery<T = Row>(
121
121
  if (sig !== snapshotCache.current.sig) {
122
122
  snapshotCache.current = { rows: filtered as T[], sig };
123
123
  }
124
- // Drop loading when rows arrive OR the initial sync settles (first server
125
- // pull done / cache had rows / fallback deadline). Gating on the settled
126
- // signal — not bare hydration — is what stops the empty-state flash: a
127
- // cold cache keeps loading=true until the pull confirms, then an empty
128
- // result is a real "no rows" and the empty state renders. No flash, and
129
- // (thanks to the fallback) no infinite spinner either.
130
- if (loading.current && (rows.length > 0 || sync.isInitialSyncSettled())) {
131
- loading.current = false;
132
- }
133
124
  return snapshotCache.current.rows;
134
125
  }, [sync, entity, optionsKey, options]);
135
126
 
@@ -137,6 +128,37 @@ export function useQuery<T = Row>(
137
128
 
138
129
  const data = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
139
130
 
131
+ // The settled signal gets its OWN subscription, with a boolean snapshot.
132
+ //
133
+ // It used to be a `useRef` flipped inside getSnapshot above, which never
134
+ // reached the render for an entity with no rows. useSyncExternalStore
135
+ // re-renders only when getSnapshot returns a value that isn't Object.is-equal
136
+ // to the last one — and the row snapshot for an empty entity is the same
137
+ // cached array before and after the pull settles. So markInitialSyncSettled()
138
+ // notified, getSnapshot ran, the ref flipped to false, React compared the
139
+ // identical array and bailed out, and the component kept rendering the stale
140
+ // `loading: true` forever. Every empty list — a new account's first dashboard,
141
+ // a fresh entity, an org whose rows all soft-deleted — sat on its skeleton
142
+ // and never reached its empty state.
143
+ //
144
+ // Booleans compare by value, so this snapshot genuinely changes when the
145
+ // engine settles and React re-renders.
146
+ const settled = useSyncExternalStore(
147
+ subscribe,
148
+ useCallback(() => sync.isInitialSyncSettled(), [sync]),
149
+ FALSE_SNAPSHOT,
150
+ );
151
+
152
+ // Derived, not latched. Loading means "we don't know yet": no server-confirmed
153
+ // view AND nothing local to show. Gating on the settled signal rather than
154
+ // bare hydration is what stops the empty-state flash — a cold cache keeps
155
+ // this true until the pull confirms, and only then is an empty result a real
156
+ // "no rows". The engine's fallback deadline settles it even offline, so this
157
+ // can't pin. Deriving it also means a replica wipe (org switch, token flip)
158
+ // resets the engine's signal and correctly returns callers to a skeleton
159
+ // instead of flashing "nothing here" over data that is on its way back.
160
+ const loading = refetching || (!settled && data.length === 0);
161
+
140
162
  // Register interest so the reconcile safety net sweeps this entity
141
163
  // even when the local replica has zero rows for it. Without this, a
142
164
  // server row in a never-cached entity (created on another surface, a
@@ -148,19 +170,21 @@ export function useQuery<T = Row>(
148
170
  }, [sync, entity]);
149
171
 
150
172
  const refetch = useCallback(() => {
151
- loading.current = true;
152
- error.current = null;
153
- sync.pull().catch((e: unknown) => {
154
- error.current = e instanceof Error ? e : new Error(String(e));
155
- });
173
+ setRefetching(true);
174
+ setError(null);
175
+ sync
176
+ .pull()
177
+ .catch((e: unknown) => {
178
+ setError(e instanceof Error ? e : new Error(String(e)));
179
+ })
180
+ // Clear on settle, success or failure. The old ref-based version set
181
+ // loading true and left it to getSnapshot to clear, which never ran on a
182
+ // pull that returned no new rows — a refetch over an empty entity pinned
183
+ // loading permanently.
184
+ .finally(() => setRefetching(false));
156
185
  }, [sync]);
157
186
 
158
- return {
159
- data,
160
- loading: loading.current,
161
- error: error.current,
162
- refetch,
163
- };
187
+ return { data, loading, error, refetch };
164
188
  }
165
189
 
166
190
  /**
@@ -175,14 +199,14 @@ export function useQueryOne<T = Row>(
175
199
  entity: string,
176
200
  id: string
177
201
  ): UseQueryOneReturn<T> {
178
- // Same initial-sync-aware loading semantics as useQuery — see the comment
179
- // there. Gates on isInitialSyncSettled() (server-confirmed) so a cold load
180
- // shows a skeleton rather than flashing "not found" before the pull lands,
181
- // and a refreshed page doesn't flash "Loading…" when the row is cached.
182
- const loading = useRef<boolean>(
183
- !sync.isInitialSyncSettled() && sync.store.get(entity, id) === null,
184
- );
185
- const error = useRef<Error | null>(null);
202
+ // Same initial-sync-aware loading semantics as useQuery — see the long
203
+ // comment there for why the settled signal needs its own boolean-valued
204
+ // subscription. This hook had the identical defect and it bit harder: a row
205
+ // that does not exist yields the same `null` snapshot before and after the
206
+ // pull, so React bailed out of the re-render and the caller was pinned in
207
+ // loading instead of ever reaching "not found".
208
+ const [error, setError] = useState<Error | null>(null);
209
+ const [refetching, setRefetching] = useState(false);
186
210
 
187
211
  const subscribe = useMemo(
188
212
  () => (onChange: () => void) => {
@@ -206,13 +230,6 @@ export function useQueryOne<T = Row>(
206
230
  if (sig !== snapshotCache.current.sig) {
207
231
  snapshotCache.current = { row: (row as T) ?? null, sig };
208
232
  }
209
- // Mirror useQuery: loading flips false once the row arrives OR the initial
210
- // sync settles (server-confirmed). Gating on isInitialSyncSettled() — not
211
- // bare hydration — keeps a cold load in the skeleton state until the pull
212
- // confirms, so a row that exists server-side doesn't flash "not found".
213
- if (loading.current && (row !== null || sync.isInitialSyncSettled())) {
214
- loading.current = false;
215
- }
216
233
  return snapshotCache.current.row;
217
234
  }, [sync, entity, id]);
218
235
 
@@ -220,6 +237,17 @@ export function useQueryOne<T = Row>(
220
237
 
221
238
  const data = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
222
239
 
240
+ const settled = useSyncExternalStore(
241
+ subscribe,
242
+ useCallback(() => sync.isInitialSyncSettled(), [sync]),
243
+ FALSE_SNAPSHOT,
244
+ );
245
+
246
+ // Loading until the row arrives OR the pull confirms it isn't there. Keeping
247
+ // a cold load in the loading state is what stops a row that DOES exist
248
+ // server-side from flashing "not found" while it is still in flight.
249
+ const loading = refetching || (!settled && data === null);
250
+
223
251
  // Register interest so reconcile sweeps this entity even with zero
224
252
  // local rows. See SyncEngine.observeEntity.
225
253
  useEffect(() => {
@@ -227,14 +255,17 @@ export function useQueryOne<T = Row>(
227
255
  }, [sync, entity]);
228
256
 
229
257
  const refetch = useCallback(() => {
230
- loading.current = true;
231
- error.current = null;
232
- sync.pull().catch((e: unknown) => {
233
- error.current = e instanceof Error ? e : new Error(String(e));
234
- });
258
+ setRefetching(true);
259
+ setError(null);
260
+ sync
261
+ .pull()
262
+ .catch((e: unknown) => {
263
+ setError(e instanceof Error ? e : new Error(String(e)));
264
+ })
265
+ .finally(() => setRefetching(false));
235
266
  }, [sync]);
236
267
 
237
- return { data, loading: loading.current, error: error.current, refetch };
268
+ return { data, loading, error, refetch };
238
269
  }
239
270
 
240
271
  // ---------------------------------------------------------------------------
@@ -0,0 +1,142 @@
1
+ // Regression coverage for `useQuery` / `useQueryOne` loading state, mounted.
2
+ //
3
+ // The bug: `loading` was a `useRef` flipped inside `getSnapshot`. React's
4
+ // useSyncExternalStore only re-renders when getSnapshot returns a value that
5
+ // isn't Object.is-equal to the previous one — and the snapshot for an entity
6
+ // with NO rows is the same cached array before and after the initial pull
7
+ // settles. So markInitialSyncSettled() notified, getSnapshot ran, the ref
8
+ // flipped to false, React compared the identical array, bailed out of the
9
+ // re-render, and the component kept rendering the stale `loading: true`.
10
+ //
11
+ // Forever. Every empty list in every Pylon app — a new account's first
12
+ // dashboard, a newly added entity, a list whose rows were all deleted — sat on
13
+ // its skeleton and never reached its empty state. It was invisible to the
14
+ // existing tests because the engine-level signal (packages/sync's
15
+ // initial-sync-loading.test.ts) was correct the whole time; only the mounted
16
+ // hook was wrong, and packages/react had no renderer to mount it with.
17
+ //
18
+ // These tests mount the real hook against the real SyncEngine. They fail with
19
+ // the ref-based implementation and pass with the derived one.
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ import { act, render, screen, waitFor, cleanup } from "@testing-library/react";
23
+ import type { SyncEngine } from "@pylonsync/sync";
24
+ import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
25
+
26
+ import { useQuery, useQueryOne } from "./hooks";
27
+
28
+ // @pylonsync/sync points `main` at src/ but `types` at dist/, so the harness
29
+ // (imported by its src path) hands back a SyncEngine whose declaration differs
30
+ // from the one the hooks are typed against — same class at runtime, two
31
+ // nominal types to tsc. One cast here beats reshaping the package's exports.
32
+ const asEngine = (e: TestEnv["engine"]): SyncEngine => e as unknown as SyncEngine;
33
+
34
+ let env: TestEnv | null = null;
35
+
36
+ afterEach(async () => {
37
+ cleanup();
38
+ if (env) {
39
+ await env.dispose();
40
+ env = null;
41
+ }
42
+ });
43
+
44
+ function ListProbe({ engine }: { engine: SyncEngine }) {
45
+ const { data, loading } = useQuery<{ id: string }>(engine, "Todo");
46
+ // Render the two facts a caller branches on. A component that never
47
+ // re-renders keeps reporting the values from its last render, which is
48
+ // exactly the failure being pinned.
49
+ return (
50
+ <div>
51
+ <span data-testid="state">{loading ? "loading" : "settled"}</span>
52
+ <span data-testid="count">{data.length}</span>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function RowProbe({ engine, id }: { engine: SyncEngine; id: string }) {
58
+ const { data, loading } = useQueryOne<{ id: string }>(engine, "Todo", id);
59
+ return (
60
+ <span data-testid="state">
61
+ {loading ? "loading" : data === null ? "not-found" : "found"}
62
+ </span>
63
+ );
64
+ }
65
+
66
+ describe("useQuery loading over an EMPTY entity", () => {
67
+ test("leaves loading once the initial pull settles, so the empty state renders", async () => {
68
+ env = createTestEnv();
69
+ env.signIn({ userId: "u1" });
70
+
71
+ render(<ListProbe engine={asEngine(env.engine)} />);
72
+
73
+ // Before the pull confirms, an empty local replica is "we don't know yet",
74
+ // not "there is nothing" — callers must be able to hold a skeleton here
75
+ // rather than flash an empty state over rows still in flight.
76
+ expect(screen.getByTestId("state").textContent).toBe("loading");
77
+
78
+ await act(async () => {
79
+ await env!.start();
80
+ });
81
+
82
+ // The crux. The engine settles with ZERO Todo rows, so the row snapshot is
83
+ // identical across the settle. If `loading` is a ref flipped inside
84
+ // getSnapshot, React bails out of this re-render and the probe is stuck
85
+ // reading "loading" — this waitFor times out.
86
+ await waitFor(() =>
87
+ expect(screen.getByTestId("state").textContent).toBe("settled"),
88
+ );
89
+ expect(screen.getByTestId("count").textContent).toBe("0");
90
+ });
91
+
92
+ test("a replica wipe returns an empty list to loading rather than flashing empty", async () => {
93
+ env = createTestEnv();
94
+ env.signIn({ userId: "u1" });
95
+ render(<ListProbe engine={asEngine(env.engine)} />);
96
+ await act(async () => {
97
+ await env!.start();
98
+ });
99
+ await waitFor(() =>
100
+ expect(screen.getByTestId("state").textContent).toBe("settled"),
101
+ );
102
+
103
+ // The org-switch path wipes the replica. The rows are empty on both sides
104
+ // of that, so a latched `loading` would stay false and the UI would assert
105
+ // "nothing here" about an org it has not fetched yet.
106
+ await act(async () => {
107
+ await env!.engine.resetReplica();
108
+ });
109
+ await waitFor(() =>
110
+ expect(screen.getByTestId("state").textContent).toBe("loading"),
111
+ );
112
+
113
+ await act(async () => {
114
+ await env!.flush();
115
+ await env!.engine.pull();
116
+ });
117
+ await waitFor(() =>
118
+ expect(screen.getByTestId("state").textContent).toBe("settled"),
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("useQueryOne loading over a MISSING row", () => {
124
+ test("reaches not-found instead of pinning on loading", async () => {
125
+ env = createTestEnv();
126
+ env.signIn({ userId: "u1" });
127
+
128
+ render(<RowProbe engine={asEngine(env.engine)} id="does-not-exist" />);
129
+ expect(screen.getByTestId("state").textContent).toBe("loading");
130
+
131
+ await act(async () => {
132
+ await env!.start();
133
+ });
134
+
135
+ // Same identical-snapshot trap, and it bit harder here: a row that does
136
+ // not exist yields `null` before AND after the pull, so nothing ever
137
+ // re-rendered and "not found" was unreachable.
138
+ await waitFor(() =>
139
+ expect(screen.getByTestId("state").textContent).toBe("not-found"),
140
+ );
141
+ });
142
+ });