ropegeo-common 1.9.0 → 1.9.2

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 CHANGED
@@ -286,7 +286,7 @@ Helper tables use columns **Name**, **Description**, **Import**. Class tables ad
286
286
  | --- | --- | --- |
287
287
  | `RopeGeoHttpRequest` | Single GET/POST wrapper; parses `Result.fromResponseBody`. | `import { RopeGeoHttpRequest, Method, Service } from 'ropegeo-common/components'` |
288
288
  | `RopeGeoCursorPaginationHttpRequest` | Cursor-paginated fetch with `loadMore`. | `import { RopeGeoCursorPaginationHttpRequest } from 'ropegeo-common/components'` |
289
- | `RopeGeoPaginationHttpRequest` | Page-based fetch; loads all pages and `mergePages` into one value. | `import { RopeGeoPaginationHttpRequest } from 'ropegeo-common/components'` |
289
+ | `RopeGeoPaginationHttpRequest<T>` | Page-based fetch; each page validated with `PaginationResults.fromResponseBody`; concatenates `results` into `data` (`T[]` when complete, otherwise `null` with `errors`). | `import { RopeGeoPaginationHttpRequest } from 'ropegeo-common/components'` |
290
290
 
291
291
  ---
292
292
 
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { type PaginationParams, PaginationResults } from "../classes";
2
+ import { type PaginationParams } from "../classes";
3
3
  import { Method, Service } from "./RopeGeoHttpRequest";
4
4
  export type RopeGeoPaginationHttpRequestProps<T = unknown> = {
5
5
  service: Service;
@@ -8,20 +8,29 @@ export type RopeGeoPaginationHttpRequestProps<T = unknown> = {
8
8
  pathParams?: Record<string, string>;
9
9
  queryParams: PaginationParams;
10
10
  /**
11
- * Combines all successful page responses into one value. Not called until every page has been fetched.
11
+ * Max concurrent page requests after page 1 completes (page 1 is always alone so `total` is known).
12
+ * Clamped to at least 1.
13
+ * @default 10
12
14
  */
13
- mergePages: (pages: PaginationResults[]) => T;
15
+ batchSize?: number;
14
16
  children: (args: {
15
17
  loading: boolean;
16
18
  received: number;
17
19
  total: number | null;
18
- data: T | null;
20
+ /**
21
+ * Concatenated `results` from every page after each body was parsed with
22
+ * {@link PaginationResults.fromResponseBody}. `null` if any page fails HTTP, JSON parse, or validation.
23
+ */
24
+ data: T[] | null;
25
+ /** Set when `data` is `null` after a terminal failure; cleared only when all pages succeed. */
19
26
  errors: Error | null;
20
27
  }) => ReactNode;
21
28
  };
22
29
  /**
23
- * Fetches page 1, 2, until all items for {@link queryParams} are loaded (same `limit` and filters,
24
- * advancing `page` via {@link PaginationParams.withPage}). The initial `page` on `queryParams` is ignored.
30
+ * Fetches page 1, then remaining pages in parallel batches of {@link batchSize}.
31
+ * The initial `page` on `queryParams` is ignored. Each body is parsed with
32
+ * {@link PaginationResults.fromResponseBody}. Final `data` is pages concatenated in page order.
33
+ * In-flight requests use one {@link AbortController}: unmount or any failure aborts the rest.
25
34
  */
26
- export declare function RopeGeoPaginationHttpRequest<T = unknown>({ service, method, path, pathParams, queryParams, mergePages, children, }: RopeGeoPaginationHttpRequestProps<T>): import("react/jsx-runtime").JSX.Element;
35
+ export declare function RopeGeoPaginationHttpRequest<T = unknown>({ service, method, path, pathParams, queryParams, batchSize, children, }: RopeGeoPaginationHttpRequestProps<T>): import("react/jsx-runtime").JSX.Element;
27
36
  //# sourceMappingURL=RopeGeoPaginationHttpRequest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"RopeGeoPaginationHttpRequest.d.ts","sourceRoot":"","sources":["../../src/components/RopeGeoPaginationHttpRequest.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,EACL,KAAK,gBAAgB,EACrB,iBAAiB,EAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAoB,MAAM,sBAAsB,CAAC;AAsCzE,MAAM,MAAM,iCAAiC,CAAC,CAAC,GAAG,OAAO,IAAI;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,OAAO,MAAM,CAAC,CAAC;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,EAAE,gBAAgB,CAAC;IAC9B;;OAEG;IACH,UAAU,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,CAAC,CAAC;IAC9C,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC;QACf,MAAM,EAAE,KAAK,GAAG,IAAI,CAAC;KACtB,KAAK,SAAS,CAAC;CACjB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,GAAG,OAAO,EAAE,EACxD,OAAO,EACP,MAAmB,EACnB,IAAI,EACJ,UAAU,EACV,WAAW,EACX,UAAU,EACV,QAAQ,GACT,EAAE,iCAAiC,CAAC,CAAC,CAAC,2CAyItC"}
1
+ {"version":3,"file":"RopeGeoPaginationHttpRequest.d.ts","sourceRoot":"","sources":["../../src/components/RopeGeoPaginationHttpRequest.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAoB,MAAM,sBAAsB,CAAC;AAoEzE,MAAM,MAAM,iCAAiC,CAAC,CAAC,GAAG,OAAO,IAAI;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,OAAO,MAAM,CAAC,CAAC;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,EAAE,gBAAgB,CAAC;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB;;;WAGG;QACH,IAAI,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC;QACjB,+FAA+F;QAC/F,MAAM,EAAE,KAAK,GAAG,IAAI,CAAC;KACtB,KAAK,SAAS,CAAC;CACjB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,GAAG,OAAO,EAAE,EACxD,OAAO,EACP,MAAmB,EACnB,IAAI,EACJ,UAAU,EACV,WAAW,EACX,SAAc,EACd,QAAQ,GACT,EAAE,iCAAiC,CAAC,CAAC,CAAC,2CA4KtC"}
@@ -29,11 +29,41 @@ function getResponseBody(raw) {
29
29
  }
30
30
  return raw;
31
31
  }
32
+ function isAbortError(e) {
33
+ if (e instanceof DOMException && e.name === "AbortError")
34
+ return true;
35
+ if (e instanceof Error && e.name === "AbortError")
36
+ return true;
37
+ return false;
38
+ }
39
+ function sumReceived(pagesByNum) {
40
+ let sum = 0;
41
+ for (const p of pagesByNum.values()) {
42
+ sum += p.results.length;
43
+ }
44
+ return sum;
45
+ }
46
+ /**
47
+ * Concatenates each page's `results` in ascending page order. Call only after every page was built via
48
+ * {@link PaginationResults.fromResponseBody}.
49
+ */
50
+ function concatPaginationResultItemsSorted(pagesByNum) {
51
+ const keys = [...pagesByNum.keys()].sort((a, b) => a - b);
52
+ const out = [];
53
+ for (const k of keys) {
54
+ const p = pagesByNum.get(k);
55
+ if (p != null)
56
+ out.push(...p.results);
57
+ }
58
+ return out;
59
+ }
32
60
  /**
33
- * Fetches page 1, 2, until all items for {@link queryParams} are loaded (same `limit` and filters,
34
- * advancing `page` via {@link PaginationParams.withPage}). The initial `page` on `queryParams` is ignored.
61
+ * Fetches page 1, then remaining pages in parallel batches of {@link batchSize}.
62
+ * The initial `page` on `queryParams` is ignored. Each body is parsed with
63
+ * {@link PaginationResults.fromResponseBody}. Final `data` is pages concatenated in page order.
64
+ * In-flight requests use one {@link AbortController}: unmount or any failure aborts the rest.
35
65
  */
36
- function RopeGeoPaginationHttpRequest({ service, method = RopeGeoHttpRequest_1.Method.GET, path, pathParams, queryParams, mergePages, children, }) {
66
+ function RopeGeoPaginationHttpRequest({ service, method = RopeGeoHttpRequest_1.Method.GET, path, pathParams, queryParams, batchSize = 10, children, }) {
37
67
  const [loading, setLoading] = (0, react_1.useState)(true);
38
68
  const [received, setReceived] = (0, react_1.useState)(0);
39
69
  const [total, setTotal] = (0, react_1.useState)(null);
@@ -41,8 +71,11 @@ function RopeGeoPaginationHttpRequest({ service, method = RopeGeoHttpRequest_1.M
41
71
  const [errors, setErrors] = (0, react_1.useState)(null);
42
72
  const pathParamsKey = JSON.stringify(pathParams ?? null);
43
73
  const queryParamsKey = queryParams.toQueryString();
74
+ const effectiveBatch = Math.max(1, Math.floor(batchSize));
44
75
  (0, react_1.useEffect)(() => {
45
76
  let cancelled = false;
77
+ const abortController = new AbortController();
78
+ const { signal } = abortController;
46
79
  setLoading(true);
47
80
  setReceived(0);
48
81
  setTotal(null);
@@ -50,87 +83,107 @@ function RopeGeoPaginationHttpRequest({ service, method = RopeGeoHttpRequest_1.M
50
83
  setErrors(null);
51
84
  const baseUrl = RopeGeoHttpRequest_1.SERVICE_BASE_URL[service];
52
85
  const resolvedPath = resolvePath(path, pathParams);
53
- const init = {
86
+ const baseInit = {
54
87
  method,
55
88
  headers: { "Content-Type": "application/json" },
89
+ signal,
56
90
  };
57
91
  (async () => {
58
- const pages = [];
59
- let pageNum = 1;
60
- let receivedCount = 0;
61
- let totalCount = null;
92
+ const pagesByNum = new Map();
93
+ const limit = queryParams.limit;
94
+ const fetchPage = async (pageNum) => {
95
+ const params = queryParams.withPage(pageNum);
96
+ const queryString = params.toQueryString();
97
+ const fullPath = queryString
98
+ ? `${resolvedPath}?${queryString}`
99
+ : resolvedPath;
100
+ const url = new URL(fullPath, baseUrl).toString();
101
+ const res = await fetch(url, baseInit);
102
+ const text = await res.text();
103
+ if (!res.ok) {
104
+ abortController.abort();
105
+ throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
106
+ }
107
+ if (text.length === 0) {
108
+ abortController.abort();
109
+ throw new Error("Empty response body");
110
+ }
111
+ let raw;
112
+ try {
113
+ raw = JSON.parse(text);
114
+ }
115
+ catch (parseError) {
116
+ abortController.abort();
117
+ console.error("[RopeGeoPaginationHttpRequest] Invalid JSON response", {
118
+ url,
119
+ status: res.status,
120
+ responseText: text.slice(0, 500),
121
+ parseError: parseError instanceof Error
122
+ ? parseError.message
123
+ : String(parseError),
124
+ });
125
+ throw new Error("Invalid JSON response");
126
+ }
127
+ try {
128
+ return classes_1.PaginationResults.fromResponseBody(getResponseBody(raw));
129
+ }
130
+ catch (e) {
131
+ abortController.abort();
132
+ const msg = e instanceof Error ? e.message : String(e);
133
+ throw new Error(msg);
134
+ }
135
+ };
62
136
  try {
63
- while (true) {
64
- const params = queryParams.withPage(pageNum);
65
- const queryString = params.toQueryString();
66
- const fullPath = queryString
67
- ? `${resolvedPath}?${queryString}`
68
- : resolvedPath;
69
- const url = new URL(fullPath, baseUrl).toString();
70
- const res = await fetch(url, init);
71
- const text = await res.text();
137
+ const first = await fetchPage(1);
138
+ if (cancelled)
139
+ return;
140
+ pagesByNum.set(1, first);
141
+ const totalCount = first.total;
142
+ let receivedCount = first.results.length;
143
+ setReceived(receivedCount);
144
+ setTotal(totalCount);
145
+ const doneByTotal = totalCount !== null && receivedCount >= totalCount;
146
+ const doneByShortPage = first.results.length < limit;
147
+ if (doneByTotal || doneByShortPage) {
72
148
  if (cancelled)
73
149
  return;
74
- if (!res.ok) {
75
- setErrors(new Error(`HTTP ${res.status}: ${text || res.statusText}`));
76
- setData(null);
77
- return;
78
- }
79
- if (text.length === 0) {
80
- setErrors(new Error("Empty response body"));
81
- setData(null);
82
- return;
83
- }
84
- let raw;
85
- try {
86
- raw = JSON.parse(text);
87
- }
88
- catch (parseError) {
89
- console.error("[RopeGeoPaginationHttpRequest] Invalid JSON response", {
90
- url,
91
- status: res.status,
92
- responseText: text.slice(0, 500),
93
- parseError: parseError instanceof Error
94
- ? parseError.message
95
- : String(parseError),
96
- });
97
- setErrors(new Error("Invalid JSON response"));
98
- setData(null);
99
- return;
100
- }
101
- let parsed;
102
- try {
103
- parsed = classes_1.PaginationResults.fromResponseBody(getResponseBody(raw));
104
- }
105
- catch (e) {
106
- const msg = e instanceof Error ? e.message : String(e);
107
- setErrors(new Error(msg));
108
- setData(null);
150
+ setData(concatPaginationResultItemsSorted(pagesByNum));
151
+ setErrors(null);
152
+ return;
153
+ }
154
+ const lastPage = Math.max(1, Math.ceil(totalCount / limit));
155
+ const toFetch = [];
156
+ for (let p = 2; p <= lastPage; p++) {
157
+ toFetch.push(p);
158
+ }
159
+ for (let i = 0; i < toFetch.length; i += effectiveBatch) {
160
+ if (cancelled)
109
161
  return;
110
- }
162
+ if (sumReceived(pagesByNum) >= totalCount)
163
+ break;
164
+ const chunk = toFetch.slice(i, i + effectiveBatch);
165
+ const batchResults = await Promise.all(chunk.map(async (pageNum) => {
166
+ const parsed = await fetchPage(pageNum);
167
+ return { pageNum, parsed };
168
+ }));
111
169
  if (cancelled)
112
170
  return;
113
- pages.push(parsed);
114
- receivedCount += parsed.results.length;
115
- if (totalCount === null) {
116
- totalCount = parsed.total;
171
+ for (const { pageNum, parsed } of batchResults) {
172
+ pagesByNum.set(pageNum, parsed);
117
173
  }
174
+ receivedCount = sumReceived(pagesByNum);
118
175
  setReceived(receivedCount);
119
176
  setTotal(totalCount);
120
- const doneByTotal = totalCount !== null && receivedCount >= totalCount;
121
- const doneByShortPage = parsed.results.length < queryParams.limit;
122
- if (doneByTotal || doneByShortPage) {
177
+ if (receivedCount >= totalCount)
123
178
  break;
124
- }
125
- pageNum += 1;
126
179
  }
127
180
  if (cancelled)
128
181
  return;
129
- setData(mergePages(pages));
182
+ setData(concatPaginationResultItemsSorted(pagesByNum));
130
183
  setErrors(null);
131
184
  }
132
185
  catch (err) {
133
- if (cancelled)
186
+ if (cancelled || isAbortError(err))
134
187
  return;
135
188
  console.error("[RopeGeoPaginationHttpRequest] Request failed", {
136
189
  error: err instanceof Error ? err.message : String(err),
@@ -145,8 +198,17 @@ function RopeGeoPaginationHttpRequest({ service, method = RopeGeoHttpRequest_1.M
145
198
  })();
146
199
  return () => {
147
200
  cancelled = true;
201
+ abortController.abort();
148
202
  };
149
- }, [service, method, path, pathParamsKey, queryParamsKey, mergePages, queryParams]);
203
+ }, [
204
+ service,
205
+ method,
206
+ path,
207
+ pathParamsKey,
208
+ queryParamsKey,
209
+ queryParams,
210
+ effectiveBatch,
211
+ ]);
150
212
  return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children({
151
213
  loading,
152
214
  received,
@@ -1 +1 @@
1
- {"version":3,"file":"httpRequest.d.ts","sourceRoot":"","sources":["../../src/helpers/httpRequest.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiDH;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAC7B,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,UAAU,SAAI,EACd,WAAW,CAAC,EAAE,WAAW,EACzB,IAAI,CAAC,EAAE,WAAW,EAClB,QAAQ,CAAC,EAAE,OAAO,GACnB,OAAO,CAAC,QAAQ,CAAC,CAiEnB;AAED,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"httpRequest.d.ts","sourceRoot":"","sources":["../../src/helpers/httpRequest.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiDH;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAC7B,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,UAAU,SAAI,EACd,WAAW,CAAC,EAAE,WAAW,EACzB,IAAI,CAAC,EAAE,WAAW,EAClB,QAAQ,CAAC,EAAE,OAAO,GACnB,OAAO,CAAC,QAAQ,CAAC,CAoEnB;AAED,eAAe,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ropegeo-common",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Shared domain classes and helpers for RopeGeo and WebScraper",
5
5
  "license": "ISC",
6
6
  "repository": {
@@ -43,11 +43,15 @@
43
43
  "@aws-sdk/client-cloudfront": "^3.971.0",
44
44
  "@aws-sdk/client-s3": "^3.971.0",
45
45
  "@aws-sdk/client-sqs": "^3.971.0",
46
+ "@testing-library/react": "^16.3.2",
46
47
  "@types/jest": "^29.5.14",
47
48
  "@types/node": "^22.10.0",
48
49
  "@types/react": "^19.0.0",
50
+ "@types/react-dom": "^19.2.3",
49
51
  "jest": "^30.2.0",
52
+ "jest-environment-jsdom": "^30.3.0",
50
53
  "react": "^19.1.0",
54
+ "react-dom": "^19.2.4",
51
55
  "ts-jest": "^29.2.5",
52
56
  "typescript": "^5.9.3",
53
57
  "undici": "^7.11.0"
@@ -55,4 +59,4 @@
55
59
  "files": [
56
60
  "dist"
57
61
  ]
58
- }
62
+ }