@pylonsync/react 0.3.358 → 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 +9 -4
- package/src/hooks.ts +85 -54
- package/src/useQuery.loading.test.tsx +142 -0
package/package.json
CHANGED
|
@@ -3,24 +3,29 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
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.
|
|
17
|
-
"@pylonsync/sync": "0.3.
|
|
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
|
-
"@
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
sync
|
|
154
|
-
|
|
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
|
|
179
|
-
// there
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
);
|
|
185
|
-
const
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
sync
|
|
233
|
-
|
|
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
|
|
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
|
+
});
|