@gusnips/react 0.3.0 → 0.4.1

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/src/index.ts CHANGED
@@ -11,17 +11,25 @@
11
11
  // @gusnips/react/ui the Base UI wrappers → @base-ui/react
12
12
  // @gusnips/react/store createAuthStore → zustand
13
13
  // @gusnips/react/guards the route guards → react-router-dom
14
+ // @gusnips/react/hydrate hydrateOrMount → react-dom
14
15
  //
15
- // So what is left here imports `react`, `react-dom` and `@gusnips/http` and nothing else, and an
16
- // app on TanStack Router or on Redux takes the fetch client without installing a router or a
17
- // store it will never call. `@tanstack/react-query`, `i18next` and `react-i18next` stay optional
18
- // AND stay here, because `query.ts`, `i18n.ts` and `states.ts` import only their TYPES — which
19
- // erase, so the built barrel does not reference them at runtime. Check that claim against
20
- // `dist/`, never against this file: a type-only re-export looks identical in source.
16
+ // `react-dom` is the one the second migration added, and it is the sharpest case yet: olhary
17
+ // ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to give.
18
+ // A required peer nobody can satisfy is not a strict contract, it is a closed door — the whole
19
+ // package was unusable there, for one function no phone would ever call. The rule did not need
20
+ // changing to catch it; `react-dom` simply had to stop being required.
21
+ //
22
+ // So what is left here imports `react` and `@gusnips/http` and nothing else, and an app on
23
+ // TanStack Router, on Redux, or on a phone takes the fetch client without installing a router, a
24
+ // store or a DOM renderer it will never call. `@tanstack/react-query`, `i18next` and
25
+ // `react-i18next` stay optional AND stay here, because `query.ts`, `i18n.ts` and `states.ts`
26
+ // import only their TYPES — which erase, so the built barrel does not reference them at runtime.
27
+ // Check that claim against `dist/`, never against this file: a type-only re-export looks
28
+ // identical in source.
21
29
 
22
30
  export { cn } from "./cn.ts";
23
31
 
24
- export { ApiError, isAbortError } from "./api-error.ts";
32
+ export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
25
33
  export {
26
34
  createApiClient,
27
35
  type ApiClient,
@@ -32,7 +40,7 @@ export {
32
40
  } from "./api-client.ts";
33
41
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
34
42
 
35
- export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
43
+ export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
36
44
 
37
45
  export {
38
46
  ErrorBoundary,
@@ -42,7 +50,6 @@ export {
42
50
  export {
43
51
  createErrorDescriber,
44
52
  humanizeWait,
45
- retryAfterSecs,
46
53
  type DescribedError,
47
54
  type ErrorArm,
48
55
  type ErrorContext,
@@ -57,7 +64,10 @@ export {
57
64
  reloadOnceForChunkError,
58
65
  } from "./chunk-reload.ts";
59
66
 
60
- export { hydrateOrMount, PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./hydrate.ts";
67
+ // The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
68
+ // it is the package's only `react-dom` import. These come from the contract module, which imports
69
+ // nothing at all, so they are free to everyone: a build script, a browser entry, or a phone.
70
+ export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
61
71
 
62
72
  export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
63
73
 
package/src/query.test.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ApiError } from "./api-error.ts";
3
- import { shouldRetry } from "./query.ts";
3
+ import { retryDelayMs, shouldRetry } from "./query.ts";
4
4
 
5
5
  const refusal = (status: number, code?: string) =>
6
6
  new ApiError(status, code ? { code, message: code } : null);
7
7
 
8
+ /** A refusal that named its own expiry, the way a `Retry-After` header does. */
9
+ const waited = (status: number, secs: number) =>
10
+ new ApiError(status, null, { retryAfterSecs: secs });
11
+
8
12
  // Invariant 8, and the reason this module exists: three donors wrote three retry rules and
9
13
  // each was right about something the other two got wrong. Every case below is one of those.
10
14
  describe("shouldRetry", () => {
@@ -51,4 +55,52 @@ describe("shouldRetry", () => {
51
55
  it("lets a durable code override the status", () => {
52
56
  expect(shouldRetry(refusal(503, "QUOTA_EXCEEDED"), ["QUOTA_EXCEEDED"])).toBe(false);
53
57
  });
58
+
59
+ // The second migration's rule. Its limiter answers `Retry-After: 60` on a per-minute window,
60
+ // so retrying at 1s and 2s spends two more requests that cannot possibly succeed.
61
+ it("does not retry a stated wait longer than we will hold", () => {
62
+ expect(shouldRetry(waited(429, 60))).toBe(false);
63
+ expect(shouldRetry(waited(429, 2))).toBe(true);
64
+ // The boundary is inclusive: exactly the ceiling is still worth waiting out.
65
+ expect(shouldRetry(waited(429, 10))).toBe(true);
66
+ expect(shouldRetry(waited(429, 11))).toBe(false);
67
+ });
68
+
69
+ it("takes the ceiling from the caller", () => {
70
+ expect(shouldRetry(waited(429, 60), [], 90)).toBe(true);
71
+ expect(shouldRetry(waited(429, 3), [], 1)).toBe(false);
72
+ });
73
+
74
+ // A long wait is an answer whatever the status says — the same judgement `durableLimitCodes`
75
+ // makes, except here the server named it and the app did not have to.
76
+ it("lets a stated wait override a 5xx", () => {
77
+ expect(shouldRetry(waited(503, 300))).toBe(false);
78
+ });
79
+
80
+ it("still retries when nothing said how long", () => {
81
+ expect(shouldRetry(refusal(429))).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("retryDelayMs", () => {
86
+ it("backs off exponentially when nothing said how long", () => {
87
+ expect(retryDelayMs(0, refusal(500))).toBe(1000);
88
+ expect(retryDelayMs(1, refusal(500))).toBe(2000);
89
+ // …and caps, so a long-lived query does not schedule a retry for next week.
90
+ expect(retryDelayMs(20, refusal(500))).toBe(30_000);
91
+ });
92
+
93
+ it("waits what the server asked for", () => {
94
+ expect(retryDelayMs(0, waited(429, 5))).toBe(5000);
95
+ });
96
+
97
+ // `Retry-After: 0` is a server saying "immediately", which for a client that was just refused
98
+ // is still too soon — the backoff is the floor.
99
+ it("never goes below the backoff", () => {
100
+ expect(retryDelayMs(1, waited(429, 0))).toBe(2000);
101
+ });
102
+
103
+ it("backs off for a failure that is not an API answer at all", () => {
104
+ expect(retryDelayMs(0, new TypeError("Failed to fetch"))).toBe(1000);
105
+ });
54
106
  });
package/src/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DefaultOptions } from "@tanstack/react-query";
2
- import { ApiError } from "./api-error.ts";
2
+ import { ApiError, retryAfterSecs } from "./api-error.ts";
3
3
 
4
4
  /**
5
5
  * react-query defaults — the merge of three repos that each got part of this right.
@@ -15,12 +15,20 @@ import { ApiError } from "./api-error.ts";
15
15
  * - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
16
16
  * most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
17
17
  *
18
+ * The second migration added the fourth rule, which no donor had: **when a 429 says how long,
19
+ * believe it.** Its limiter answers `Retry-After: 60` on a per-minute window, and the default
20
+ * backoff retries at 1s and 2s — two more requests that cannot succeed, both charged against the
21
+ * same limiter, and three seconds added before the screen says anything. So a stated wait is
22
+ * waited out when it is short, and is an ANSWER when it is not.
23
+ *
18
24
  * This is invariant 8.
19
25
  */
20
26
 
21
27
  /** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
22
28
  const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
23
29
 
30
+ const DEFAULT_MAX_RETRY_WAIT_SECS = 10;
31
+
24
32
  export interface QueryDefaultsOptions {
25
33
  /**
26
34
  * Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
@@ -30,6 +38,15 @@ export interface QueryDefaultsOptions {
30
38
  durableLimitCodes?: readonly string[];
31
39
  /** How many times to retry a transient failure. Two donors used 2, one used 1. */
32
40
  maxRetries?: number;
41
+ /**
42
+ * Longest stated wait still worth retrying. Default 10 seconds.
43
+ *
44
+ * Only applies when the refusal named its own expiry (`Retry-After`, or `retryAfterSecs` in
45
+ * the body). A burst limit that clears in two seconds is worth sitting out; a monthly ceiling
46
+ * that clears in an hour is an answer, and holding a spinner for it helps nobody — show the
47
+ * wait instead, which is what `humanizeWait` is for.
48
+ */
49
+ maxRetryWaitSecs?: number;
33
50
  staleTime?: number;
34
51
  gcTime?: number;
35
52
  /**
@@ -46,19 +63,42 @@ export interface QueryDefaultsOptions {
46
63
  * Exported on its own because an app with its own QueryClient config still wants this rule, and
47
64
  * because it is the part worth testing.
48
65
  */
49
- export function shouldRetry(error: unknown, durableLimitCodes: readonly string[] = []): boolean {
66
+ export function shouldRetry(
67
+ error: unknown,
68
+ durableLimitCodes: readonly string[] = [],
69
+ maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS,
70
+ ): boolean {
50
71
  // No response at all — offline, DNS, a dropped connection. The request never landed, so
51
72
  // nothing about it is an answer, and a second attempt is exactly right.
52
73
  if (!(error instanceof ApiError)) return true;
53
74
 
54
75
  if (error.code !== undefined && durableLimitCodes.includes(error.code)) return false;
76
+
77
+ // It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
78
+ // and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
79
+ const wait = retryAfterSecs(error);
80
+ if (wait !== null && wait > maxRetryWaitSecs) return false;
81
+
55
82
  if (error.status >= 500) return true;
56
83
  return RETRYABLE_CLIENT_STATUS.has(error.status);
57
84
  }
58
85
 
86
+ /**
87
+ * How long before the next attempt: the server's stated wait when there is one, react-query's
88
+ * exponential backoff otherwise, and never less than the backoff — a `Retry-After: 0` is a
89
+ * server saying "immediately", which for a client that just got refused is still too soon.
90
+ */
91
+ export function retryDelayMs(attemptIndex: number, error: unknown): number {
92
+ const backoff = Math.min(1000 * 2 ** attemptIndex, 30_000);
93
+ if (!(error instanceof ApiError)) return backoff;
94
+ const wait = retryAfterSecs(error);
95
+ return wait === null ? backoff : Math.max(backoff, wait * 1000);
96
+ }
97
+
59
98
  export function queryDefaults({
60
99
  durableLimitCodes = [],
61
100
  maxRetries = 2,
101
+ maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS,
62
102
  staleTime = 2 * 60 * 1000,
63
103
  gcTime = 10 * 60 * 1000,
64
104
  refetchOnWindowFocus = false,
@@ -68,7 +108,8 @@ export function queryDefaults({
68
108
  staleTime,
69
109
  gcTime,
70
110
  retry: (failureCount, error) =>
71
- failureCount < maxRetries && shouldRetry(error, durableLimitCodes),
111
+ failureCount < maxRetries && shouldRetry(error, durableLimitCodes, maxRetryWaitSecs),
112
+ retryDelay: retryDelayMs,
72
113
  refetchOnWindowFocus,
73
114
  refetchOnReconnect: true,
74
115
  },