@gusnips/react 0.9.8 → 0.9.9
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/README.md +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +38 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +8 -0
- package/dist/query.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/query.test.ts +69 -1
- package/src/query.ts +39 -0
package/README.md
CHANGED
|
@@ -178,6 +178,33 @@ saying waiting will never fix this — a cap that frees only when somebody delet
|
|
|
178
178
|
slot that frees when another job ends. That `null` is read as the claim it is, not as a field
|
|
179
179
|
somebody forgot, so the code never reaches your list.
|
|
180
180
|
|
|
181
|
+
## A failed refresh is not a failed page
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { queryView } from "@gusnips/react";
|
|
185
|
+
|
|
186
|
+
const view = queryView(numbers); // what useQuery returned
|
|
187
|
+
|
|
188
|
+
if (view.state === "waiting") return <Spinner />;
|
|
189
|
+
if (view.state === "failed") return <ErrorScreen error={view.error} />;
|
|
190
|
+
return <Numbers data={view.data} />;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Don't write `isError ? <ErrorScreen /> : <Numbers />`. `isError` is also true when a refresh fails
|
|
194
|
+
in the background while the numbers are already on screen: the window gets focus back, a poll
|
|
195
|
+
runs, a save reloads the list. One dropped request then swaps a working screen for an error.
|
|
196
|
+
`queryView` shows the error only when there is nothing else to show.
|
|
197
|
+
|
|
198
|
+
When a refresh fails, the view stays `ready` and `refreshError` holds the error. Use it to say the
|
|
199
|
+
numbers may be out of date, with a button to try again.
|
|
200
|
+
|
|
201
|
+
`waiting` also covers a query that is switched off until something else loads. `isLoading` is
|
|
202
|
+
false there, so `isLoading ? <Spinner /> : <List />` shows an empty list, as if the answer were
|
|
203
|
+
"nothing".
|
|
204
|
+
|
|
205
|
+
It reads only `data` and `error`, so a test can pass `{ data: 2, error: null }`. Which button to
|
|
206
|
+
offer comes from `describeError(view.error).recover`, below.
|
|
207
|
+
|
|
181
208
|
## Survives your deploys
|
|
182
209
|
|
|
183
210
|
An app with `lazy()` routes serves chunks by hashed filename. Deploy while someone has a tab
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { returnPathFromLocation, safeInternalPath, type PathParts } from "./inte
|
|
|
3
3
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
4
4
|
export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
|
|
5
5
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
6
|
-
export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
|
|
6
|
+
export { queryDefaults, queryView, retryDelayMs, shouldRetry, type QueryDefaultsOptions, type QueryView, } from "./query.ts";
|
|
7
7
|
export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
|
|
8
8
|
export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorCopy, type ErrorDescriberOptions, type RecoveryKind, type Translate, } from "./describe-error.ts";
|
|
9
9
|
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.ts";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
|
34
34
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.js";
|
|
35
35
|
export { createApiClient, } from "./api-client.js";
|
|
36
36
|
export { createSseParser, readSseStream } from "./sse.js";
|
|
37
|
-
export { queryDefaults, retryDelayMs, shouldRetry } from "./query.js";
|
|
37
|
+
export { queryDefaults, queryView, retryDelayMs, shouldRetry, } from "./query.js";
|
|
38
38
|
export { ErrorBoundary, } from "./error-boundary.js";
|
|
39
39
|
export { createErrorDescriber, humanizeWait, } from "./describe-error.js";
|
|
40
40
|
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
package/dist/query.d.ts
CHANGED
|
@@ -44,4 +44,42 @@ export declare function shouldRetry(error: unknown, durableLimitCodes?: readonly
|
|
|
44
44
|
*/
|
|
45
45
|
export declare function retryDelayMs(attemptIndex: number, error: unknown): number;
|
|
46
46
|
export declare function queryDefaults({ durableLimitCodes, maxRetries, maxRetryWaitSecs, staleTime, gcTime, refetchOnWindowFocus, }?: QueryDefaultsOptions): DefaultOptions;
|
|
47
|
+
/**
|
|
48
|
+
* What a screen should show for one query: still waiting, failed with nothing to show, or ready.
|
|
49
|
+
* When it is ready, `refreshError` says whether the last refresh failed.
|
|
50
|
+
*
|
|
51
|
+
* **Never gate a screen on `isError`.** It is also true when a BACKGROUND refetch fails over data
|
|
52
|
+
* already on screen — a window regaining focus, a poll, the invalidation after a save — so
|
|
53
|
+
* `isError ? <failure panel> : <data>` throws away what the reader was looking at over one blip.
|
|
54
|
+
* The adopters that had noticed each wrote `isLoadingError` beside a comment saying so; the ones
|
|
55
|
+
* that had not were a boundary and a great many inline ternaries. A refresh that failed is not a
|
|
56
|
+
* page that failed: the data is still real, and `refreshError` is there to say it may be stale.
|
|
57
|
+
*
|
|
58
|
+
* `waiting` is anything with no data and no error, and that includes a query switched off until
|
|
59
|
+
* something upstream answers. `isLoading` is false there, because it means `isPending &&
|
|
60
|
+
* isFetching`, which is how `isLoading ? <spinner> : <list>` shows an empty list as though the
|
|
61
|
+
* answer were "nothing".
|
|
62
|
+
*
|
|
63
|
+
* Only `data` and `error` are read, and that is react-query's own definition, not a shortcut:
|
|
64
|
+
* every success clears `error`, and a refetch after a failed first load goes back to pending. So
|
|
65
|
+
* an error beside data IS `isRefetchError`, and an error without data IS `isLoadingError`. It
|
|
66
|
+
* takes any query result, infinite ones included, and a test passes a plain object.
|
|
67
|
+
*
|
|
68
|
+
* Which control to offer is not decided here. Hand the error to the describer: its `recover` comes
|
|
69
|
+
* from the same rule the query client retries by, so the button and the retry cannot disagree.
|
|
70
|
+
*/
|
|
71
|
+
export type QueryView<T, E = unknown> = {
|
|
72
|
+
readonly state: "waiting";
|
|
73
|
+
} | {
|
|
74
|
+
readonly state: "failed";
|
|
75
|
+
readonly error: E;
|
|
76
|
+
} | {
|
|
77
|
+
readonly state: "ready";
|
|
78
|
+
readonly data: T;
|
|
79
|
+
readonly refreshError: E | null;
|
|
80
|
+
};
|
|
81
|
+
export declare function queryView<T, E>(query: {
|
|
82
|
+
readonly data: T | undefined;
|
|
83
|
+
readonly error: E | null;
|
|
84
|
+
}): QueryView<T, E>;
|
|
47
85
|
//# sourceMappingURL=query.d.ts.map
|
package/dist/query.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAoC5D,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,iBAAiB,GAAE,SAAS,MAAM,EAAO,EACzC,gBAAgB,SAA8B,GAC7C,OAAO,CAqBT;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAKzE;AAED,wBAAgB,aAAa,CAAC,EAC5B,iBAAsB,EACtB,UAAc,EACd,gBAA8C,EAC9C,SAAyB,EACzB,MAAuB,EACvB,oBAA4B,GAC7B,GAAE,oBAAyB,GAAG,cAAc,CAe5C"}
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAoC5D,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,iBAAiB,GAAE,SAAS,MAAM,EAAO,EACzC,gBAAgB,SAA8B,GAC7C,OAAO,CAqBT;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAKzE;AAED,wBAAgB,aAAa,CAAC,EAC5B,iBAAsB,EACtB,UAAc,EACd,gBAA8C,EAC9C,SAAyB,EACzB,MAAuB,EACvB,oBAA4B,GAC7B,GAAE,oBAAyB,GAAG,cAAc,CAe5C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,IAChC;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAC7B;IAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,GAAG,IAAI,CAAA;CAAE,CAAC;AAEnF,wBAAgB,SAAS,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE;IACrC,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,SAAS,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;CAC1B,GAAG,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAKlB"}
|
package/dist/query.js
CHANGED
|
@@ -85,4 +85,12 @@ export function queryDefaults({ durableLimitCodes = [], maxRetries = 2, maxRetry
|
|
|
85
85
|
mutations: { retry: false },
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
export function queryView(query) {
|
|
89
|
+
const { data, error } = query;
|
|
90
|
+
if (data !== undefined)
|
|
91
|
+
return { state: "ready", data, refreshError: error };
|
|
92
|
+
if (error !== null)
|
|
93
|
+
return { state: "failed", error };
|
|
94
|
+
return { state: "waiting" };
|
|
95
|
+
}
|
|
88
96
|
//# sourceMappingURL=query.js.map
|
package/dist/query.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,6FAA6F;AAC7F,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAEpD,MAAM,2BAA2B,GAAG,EAAE,CAAC;AAkCvC;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CACzB,KAAc,EACd,oBAAuC,EAAE,EACzC,gBAAgB,GAAG,2BAA2B;IAE9C,wFAAwF;IACxF,wEAAwE;IACxE,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9C,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,iBAAiB,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAErF,6FAA6F;IAC7F,8FAA8F;IAC9F,8FAA8F;IAC9F,4FAA4F;IAC5F,gBAAgB;IAChB,IAAI,iBAAiB,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAE3C,4FAA4F;IAC5F,4FAA4F;IAC5F,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,GAAG,gBAAgB;QAAE,OAAO,KAAK,CAAC;IAE3D,IAAI,KAAK,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,uBAAuB,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,YAAoB,EAAE,KAAc;IAC/D,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,IAAI,YAAY,EAAE,MAAM,CAAC,CAAC;IAC3D,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACjD,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,OAAO,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,GAAG,IAAI,CAAC,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAC5B,iBAAiB,GAAG,EAAE,EACtB,UAAU,GAAG,CAAC,EACd,gBAAgB,GAAG,2BAA2B,EAC9C,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,EACzB,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EACvB,oBAAoB,GAAG,KAAK,MACJ,EAAE;IAC1B,OAAO;QACL,OAAO,EAAE;YACP,SAAS;YACT,MAAM;YACN,KAAK,EAAE,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,CAC7B,YAAY,GAAG,UAAU,IAAI,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC;YACtF,UAAU,EAAE,YAAY;YACxB,oBAAoB;YACpB,kBAAkB,EAAE,IAAI;SACzB;QACD,2FAA2F;QAC3F,yFAAyF;QACzF,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC"}
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,14 @@ export {
|
|
|
44
44
|
} from "./api-client.ts";
|
|
45
45
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
46
46
|
|
|
47
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
queryDefaults,
|
|
49
|
+
queryView,
|
|
50
|
+
retryDelayMs,
|
|
51
|
+
shouldRetry,
|
|
52
|
+
type QueryDefaultsOptions,
|
|
53
|
+
type QueryView,
|
|
54
|
+
} from "./query.ts";
|
|
48
55
|
|
|
49
56
|
export {
|
|
50
57
|
ErrorBoundary,
|
package/src/query.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { QueryClient, QueryObserver, type QueryObserverResult } from "@tanstack/react-query";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import { ApiError } from "./api-error.ts";
|
|
3
|
-
import { retryDelayMs, shouldRetry } from "./query.ts";
|
|
4
|
+
import { queryView, retryDelayMs, shouldRetry } from "./query.ts";
|
|
4
5
|
|
|
5
6
|
const refusal = (status: number, code?: string) =>
|
|
6
7
|
new ApiError(status, code ? { code, message: code } : null);
|
|
@@ -144,3 +145,70 @@ describe("retryDelayMs", () => {
|
|
|
144
145
|
expect(retryDelayMs(0, new TypeError("Failed to fetch"))).toBe(1000);
|
|
145
146
|
});
|
|
146
147
|
});
|
|
148
|
+
|
|
149
|
+
describe("queryView", () => {
|
|
150
|
+
/**
|
|
151
|
+
* One real query, taken through every state a screen meets, with `queryView` checked against
|
|
152
|
+
* react-query's own flags at each step. A plain object would only check this file against
|
|
153
|
+
* itself; the observer is what says the reading of `data` and `error` is react-query's.
|
|
154
|
+
*/
|
|
155
|
+
it("agrees with react-query's own flags through a query's whole life", async () => {
|
|
156
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
157
|
+
let answer: () => number = () => {
|
|
158
|
+
throw new Error("down");
|
|
159
|
+
};
|
|
160
|
+
const observer = new QueryObserver(client, {
|
|
161
|
+
queryKey: ["numbers"],
|
|
162
|
+
queryFn: async () => answer(),
|
|
163
|
+
enabled: false,
|
|
164
|
+
});
|
|
165
|
+
// Subscribed, as a mounted `useQuery` is. An observer nobody listens to never hears the query
|
|
166
|
+
// change, and reads the last result it computed.
|
|
167
|
+
const unsubscribe = observer.subscribe(() => {});
|
|
168
|
+
const now = () => {
|
|
169
|
+
const result: QueryObserverResult<number, Error> = observer.getCurrentResult();
|
|
170
|
+
const view = queryView(result);
|
|
171
|
+
expect(view.state === "failed").toBe(result.isLoadingError);
|
|
172
|
+
expect(view.state === "ready" && view.refreshError !== null).toBe(result.isRefetchError);
|
|
173
|
+
return { result, view };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Switched off until something upstream answers: pending, but not loading.
|
|
177
|
+
const off = now();
|
|
178
|
+
expect(off.result.isLoading).toBe(false);
|
|
179
|
+
expect(off.view).toEqual({ state: "waiting" });
|
|
180
|
+
|
|
181
|
+
await observer.refetch();
|
|
182
|
+
expect(now().view.state).toBe("failed");
|
|
183
|
+
|
|
184
|
+
// Retrying a failed first load goes back to pending, so the reader sees it working.
|
|
185
|
+
answer = () => 2;
|
|
186
|
+
const retry = observer.refetch();
|
|
187
|
+
expect(now().view.state).toBe("waiting");
|
|
188
|
+
await retry;
|
|
189
|
+
expect(now().view).toEqual({ state: "ready", data: 2, refreshError: null });
|
|
190
|
+
|
|
191
|
+
// The case this exists for: a refresh fails over data already on screen.
|
|
192
|
+
answer = () => {
|
|
193
|
+
throw new Error("blip");
|
|
194
|
+
};
|
|
195
|
+
await observer.refetch();
|
|
196
|
+
const stale = now();
|
|
197
|
+
expect(stale.result.isError).toBe(true);
|
|
198
|
+
expect(stale.view).toMatchObject({ state: "ready", data: 2 });
|
|
199
|
+
expect(stale.view.state === "ready" && stale.view.refreshError?.message).toBe("blip");
|
|
200
|
+
|
|
201
|
+
answer = () => 3;
|
|
202
|
+
await observer.refetch();
|
|
203
|
+
expect(now().view).toEqual({ state: "ready", data: 3, refreshError: null });
|
|
204
|
+
unsubscribe();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("treats null as an answer, not as waiting", () => {
|
|
208
|
+
expect(queryView({ data: null, error: null })).toEqual({
|
|
209
|
+
state: "ready",
|
|
210
|
+
data: null,
|
|
211
|
+
refreshError: null,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -134,3 +134,42 @@ export function queryDefaults({
|
|
|
134
134
|
mutations: { retry: false },
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a screen should show for one query: still waiting, failed with nothing to show, or ready.
|
|
140
|
+
* When it is ready, `refreshError` says whether the last refresh failed.
|
|
141
|
+
*
|
|
142
|
+
* **Never gate a screen on `isError`.** It is also true when a BACKGROUND refetch fails over data
|
|
143
|
+
* already on screen — a window regaining focus, a poll, the invalidation after a save — so
|
|
144
|
+
* `isError ? <failure panel> : <data>` throws away what the reader was looking at over one blip.
|
|
145
|
+
* The adopters that had noticed each wrote `isLoadingError` beside a comment saying so; the ones
|
|
146
|
+
* that had not were a boundary and a great many inline ternaries. A refresh that failed is not a
|
|
147
|
+
* page that failed: the data is still real, and `refreshError` is there to say it may be stale.
|
|
148
|
+
*
|
|
149
|
+
* `waiting` is anything with no data and no error, and that includes a query switched off until
|
|
150
|
+
* something upstream answers. `isLoading` is false there, because it means `isPending &&
|
|
151
|
+
* isFetching`, which is how `isLoading ? <spinner> : <list>` shows an empty list as though the
|
|
152
|
+
* answer were "nothing".
|
|
153
|
+
*
|
|
154
|
+
* Only `data` and `error` are read, and that is react-query's own definition, not a shortcut:
|
|
155
|
+
* every success clears `error`, and a refetch after a failed first load goes back to pending. So
|
|
156
|
+
* an error beside data IS `isRefetchError`, and an error without data IS `isLoadingError`. It
|
|
157
|
+
* takes any query result, infinite ones included, and a test passes a plain object.
|
|
158
|
+
*
|
|
159
|
+
* Which control to offer is not decided here. Hand the error to the describer: its `recover` comes
|
|
160
|
+
* from the same rule the query client retries by, so the button and the retry cannot disagree.
|
|
161
|
+
*/
|
|
162
|
+
export type QueryView<T, E = unknown> =
|
|
163
|
+
| { readonly state: "waiting" }
|
|
164
|
+
| { readonly state: "failed"; readonly error: E }
|
|
165
|
+
| { readonly state: "ready"; readonly data: T; readonly refreshError: E | null };
|
|
166
|
+
|
|
167
|
+
export function queryView<T, E>(query: {
|
|
168
|
+
readonly data: T | undefined;
|
|
169
|
+
readonly error: E | null;
|
|
170
|
+
}): QueryView<T, E> {
|
|
171
|
+
const { data, error } = query;
|
|
172
|
+
if (data !== undefined) return { state: "ready", data, refreshError: error };
|
|
173
|
+
if (error !== null) return { state: "failed", error };
|
|
174
|
+
return { state: "waiting" };
|
|
175
|
+
}
|