@choksheak/react-utils 0.2.6 → 0.2.8

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/fetcher.cjs CHANGED
@@ -31,21 +31,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var fetcher_exports = {};
32
32
  __export(fetcher_exports, {
33
33
  fetcher: () => fetcher,
34
- fetcherWithRetries: () => fetcherWithRetries
34
+ simpleFetcher: () => simpleFetcher
35
35
  });
36
36
  module.exports = __toCommonJS(fetcher_exports);
37
37
  var import_wretch = __toESM(require("wretch"));
38
38
  var import_middlewares = require("wretch/middlewares");
39
- var fetcher = (0, import_wretch.default)();
40
- var fetcherWithRetries = (0, import_wretch.default)().middlewares([
39
+ var fetcher = (0, import_wretch.default)().middlewares([
41
40
  (0, import_middlewares.retry)({
42
41
  maxAttempts: 3,
43
42
  delayTimer: 1e3,
44
43
  retryOnNetworkError: true
45
44
  })
46
45
  ]);
46
+ var simpleFetcher = (0, import_wretch.default)();
47
47
  // Annotate the CommonJS export names for ESM import in node:
48
48
  0 && (module.exports = {
49
49
  fetcher,
50
- fetcherWithRetries
50
+ simpleFetcher
51
51
  });
package/fetcher.d.mts CHANGED
@@ -1,8 +1,35 @@
1
1
  import * as wretch from 'wretch';
2
2
 
3
- /** Name this as "fetcher" to make it easier to understand. */
3
+ /**
4
+ * @packageDocumentation
5
+ *
6
+ * An alternate syntax to fetch() that allows function chaining to compose the
7
+ * entire fetch request. One can also compose partial fetcher objects to be
8
+ * reused in future requests.
9
+ *
10
+ * Examples:
11
+ * ```
12
+ * const userFetcher = fetcher
13
+ * .auth("Bearer TOPSECRET")
14
+ * .content("application/json")
15
+ * .url("/api/user");
16
+ *
17
+ * const user = await userFetcher.get();
18
+ *
19
+ * const updateResult = await userFetcher
20
+ * .body({ id: user.id, name: "New name"})
21
+ * .post();
22
+ * ```
23
+ */
24
+ /**
25
+ * Fetcher with default short retries. Use this to make your queries more
26
+ * robust without any additional work.
27
+ */
4
28
  declare const fetcher: wretch.Wretch<unknown, unknown, undefined, undefined>;
5
- /** Fetcher with default short retries. */
6
- declare const fetcherWithRetries: wretch.Wretch<unknown, unknown, undefined, undefined>;
29
+ /**
30
+ * Name this as `simpleFetcher` to make it easier to understand than `wretch`.
31
+ * Use this if you don't want retries. Use `fetcher` if you want retries.
32
+ */
33
+ declare const simpleFetcher: wretch.Wretch<unknown, unknown, undefined, undefined>;
7
34
 
8
- export { fetcher, fetcherWithRetries };
35
+ export { fetcher, simpleFetcher };
package/fetcher.d.ts CHANGED
@@ -1,4 +1,31 @@
1
- /** Name this as "fetcher" to make it easier to understand. */
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * An alternate syntax to fetch() that allows function chaining to compose the
5
+ * entire fetch request. One can also compose partial fetcher objects to be
6
+ * reused in future requests.
7
+ *
8
+ * Examples:
9
+ * ```
10
+ * const userFetcher = fetcher
11
+ * .auth("Bearer TOPSECRET")
12
+ * .content("application/json")
13
+ * .url("/api/user");
14
+ *
15
+ * const user = await userFetcher.get();
16
+ *
17
+ * const updateResult = await userFetcher
18
+ * .body({ id: user.id, name: "New name"})
19
+ * .post();
20
+ * ```
21
+ */
22
+ /**
23
+ * Fetcher with default short retries. Use this to make your queries more
24
+ * robust without any additional work.
25
+ */
2
26
  export declare const fetcher: import("wretch").Wretch<unknown, unknown, undefined, undefined>;
3
- /** Fetcher with default short retries. */
4
- export declare const fetcherWithRetries: import("wretch").Wretch<unknown, unknown, undefined, undefined>;
27
+ /**
28
+ * Name this as `simpleFetcher` to make it easier to understand than `wretch`.
29
+ * Use this if you don't want retries. Use `fetcher` if you want retries.
30
+ */
31
+ export declare const simpleFetcher: import("wretch").Wretch<unknown, unknown, undefined, undefined>;
package/fetcher.min.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";var f=Object.create;var m=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var d=Object.getPrototypeOf,n=Object.prototype.hasOwnProperty;var w=(r,e)=>{for(var t in e)m(r,t,{get:e[t],enumerable:!0})},p=(r,e,t,c)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of a(e))!n.call(r,o)&&o!==t&&m(r,o,{get:()=>e[o],enumerable:!(c=h(e,o))||c.enumerable});return r};var x=(r,e,t)=>(t=r!=null?f(d(r)):{},p(e||!r||!r.__esModule?m(t,"default",{value:r,enumerable:!0}):t,r)),y=r=>p(m({},"__esModule",{value:!0}),r);var u={};w(u,{fetcher:()=>l,fetcherWithRetries:()=>k});module.exports=y(u);var i=x(require("wretch")),s=require("wretch/middlewares"),l=(0,i.default)(),k=(0,i.default)().middlewares([(0,s.retry)({maxAttempts:3,delayTimer:1e3,retryOnNetworkError:!0})]);0&&(module.exports={fetcher,fetcherWithRetries});
1
+ "use strict";var a=Object.create;var m=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var h=Object.getPrototypeOf,l=Object.prototype.hasOwnProperty;var n=(r,e)=>{for(var t in e)m(r,t,{get:e[t],enumerable:!0})},i=(r,e,t,c)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of f(e))!l.call(r,o)&&o!==t&&m(r,o,{get:()=>e[o],enumerable:!(c=d(e,o))||c.enumerable});return r};var w=(r,e,t)=>(t=r!=null?a(h(r)):{},i(e||!r||!r.__esModule?m(t,"default",{value:r,enumerable:!0}):t,r)),x=r=>i(m({},"__esModule",{value:!0}),r);var u={};n(u,{fetcher:()=>y,simpleFetcher:()=>k});module.exports=x(u);var p=w(require("wretch")),s=require("wretch/middlewares"),y=(0,p.default)().middlewares([(0,s.retry)({maxAttempts:3,delayTimer:1e3,retryOnNetworkError:!0})]),k=(0,p.default)();0&&(module.exports={fetcher,simpleFetcher});
2
2
  //# sourceMappingURL=fetcher.min.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fetcher.ts"],"sourcesContent":["import wretch from \"wretch\";\nimport { retry } from \"wretch/middlewares\";\n\n/** Name this as \"fetcher\" to make it easier to understand. */\nexport const fetcher = wretch();\n\n/** Fetcher with default short retries. */\nexport const fetcherWithRetries = wretch().middlewares([\n retry({\n maxAttempts: 3,\n delayTimer: 1000,\n retryOnNetworkError: true,\n }),\n]);\n"],"mappings":"0jBAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,aAAAE,EAAA,uBAAAC,IAAA,eAAAC,EAAAJ,GAAA,IAAAK,EAAmB,qBACnBC,EAAsB,8BAGTJ,KAAU,EAAAK,SAAO,EAGjBJ,KAAqB,EAAAI,SAAO,EAAE,YAAY,IACrD,SAAM,CACJ,YAAa,EACb,WAAY,IACZ,oBAAqB,EACvB,CAAC,CACH,CAAC","names":["fetcher_exports","__export","fetcher","fetcherWithRetries","__toCommonJS","import_wretch","import_middlewares","wretch"]}
1
+ {"version":3,"sources":["../src/fetcher.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * An alternate syntax to fetch() that allows function chaining to compose the\n * entire fetch request. One can also compose partial fetcher objects to be\n * reused in future requests.\n *\n * Examples:\n * ```\n * const userFetcher = fetcher\n * .auth(\"Bearer TOPSECRET\")\n * .content(\"application/json\")\n * .url(\"/api/user\");\n *\n * const user = await userFetcher.get();\n *\n * const updateResult = await userFetcher\n * .body({ id: user.id, name: \"New name\"})\n * .post();\n * ```\n */\n\nimport wretch from \"wretch\";\nimport { retry } from \"wretch/middlewares\";\n\n/**\n * Fetcher with default short retries. Use this to make your queries more\n * robust without any additional work.\n */\nexport const fetcher = wretch().middlewares([\n retry({\n maxAttempts: 3,\n delayTimer: 1000,\n retryOnNetworkError: true,\n }),\n]);\n\n/**\n * Name this as `simpleFetcher` to make it easier to understand than `wretch`.\n * Use this if you don't want retries. Use `fetcher` if you want retries.\n */\nexport const simpleFetcher = wretch();\n"],"mappings":"0jBAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,aAAAE,EAAA,kBAAAC,IAAA,eAAAC,EAAAJ,GAsBA,IAAAK,EAAmB,qBACnBC,EAAsB,8BAMTJ,KAAU,EAAAK,SAAO,EAAE,YAAY,IAC1C,SAAM,CACJ,YAAa,EACb,WAAY,IACZ,oBAAqB,EACvB,CAAC,CACH,CAAC,EAMYJ,KAAgB,EAAAI,SAAO","names":["fetcher_exports","__export","fetcher","simpleFetcher","__toCommonJS","import_wretch","import_middlewares","wretch"]}
package/fetcher.min.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import r from"wretch";import{retry as e}from"wretch/middlewares";var m=r(),i=r().middlewares([e({maxAttempts:3,delayTimer:1e3,retryOnNetworkError:!0})]);export{m as fetcher,i as fetcherWithRetries};
1
+ import r from"wretch";import{retry as e}from"wretch/middlewares";var m=r().middlewares([e({maxAttempts:3,delayTimer:1e3,retryOnNetworkError:!0})]),p=r();export{m as fetcher,p as simpleFetcher};
2
2
  //# sourceMappingURL=fetcher.min.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fetcher.ts"],"sourcesContent":["import wretch from \"wretch\";\nimport { retry } from \"wretch/middlewares\";\n\n/** Name this as \"fetcher\" to make it easier to understand. */\nexport const fetcher = wretch();\n\n/** Fetcher with default short retries. */\nexport const fetcherWithRetries = wretch().middlewares([\n retry({\n maxAttempts: 3,\n delayTimer: 1000,\n retryOnNetworkError: true,\n }),\n]);\n"],"mappings":"AAAA,OAAOA,MAAY,SACnB,OAAS,SAAAC,MAAa,qBAGf,IAAMC,EAAUF,EAAO,EAGjBG,EAAqBH,EAAO,EAAE,YAAY,CACrDC,EAAM,CACJ,YAAa,EACb,WAAY,IACZ,oBAAqB,EACvB,CAAC,CACH,CAAC","names":["wretch","retry","fetcher","fetcherWithRetries"]}
1
+ {"version":3,"sources":["../src/fetcher.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * An alternate syntax to fetch() that allows function chaining to compose the\n * entire fetch request. One can also compose partial fetcher objects to be\n * reused in future requests.\n *\n * Examples:\n * ```\n * const userFetcher = fetcher\n * .auth(\"Bearer TOPSECRET\")\n * .content(\"application/json\")\n * .url(\"/api/user\");\n *\n * const user = await userFetcher.get();\n *\n * const updateResult = await userFetcher\n * .body({ id: user.id, name: \"New name\"})\n * .post();\n * ```\n */\n\nimport wretch from \"wretch\";\nimport { retry } from \"wretch/middlewares\";\n\n/**\n * Fetcher with default short retries. Use this to make your queries more\n * robust without any additional work.\n */\nexport const fetcher = wretch().middlewares([\n retry({\n maxAttempts: 3,\n delayTimer: 1000,\n retryOnNetworkError: true,\n }),\n]);\n\n/**\n * Name this as `simpleFetcher` to make it easier to understand than `wretch`.\n * Use this if you don't want retries. Use `fetcher` if you want retries.\n */\nexport const simpleFetcher = wretch();\n"],"mappings":"AAsBA,OAAOA,MAAY,SACnB,OAAS,SAAAC,MAAa,qBAMf,IAAMC,EAAUF,EAAO,EAAE,YAAY,CAC1CC,EAAM,CACJ,YAAa,EACb,WAAY,IACZ,oBAAqB,EACvB,CAAC,CACH,CAAC,EAMYE,EAAgBH,EAAO","names":["wretch","retry","fetcher","simpleFetcher"]}
package/fetcher.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  // src/fetcher.ts
2
2
  import wretch from "wretch";
3
3
  import { retry } from "wretch/middlewares";
4
- var fetcher = wretch();
5
- var fetcherWithRetries = wretch().middlewares([
4
+ var fetcher = wretch().middlewares([
6
5
  retry({
7
6
  maxAttempts: 3,
8
7
  delayTimer: 1e3,
9
8
  retryOnNetworkError: true
10
9
  })
11
10
  ]);
11
+ var simpleFetcher = wretch();
12
12
  export {
13
13
  fetcher,
14
- fetcherWithRetries
14
+ simpleFetcher
15
15
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@choksheak/react-utils",
3
3
  "license": "The Unlicense",
4
- "version": "0.2.6",
4
+ "version": "0.2.8",
5
5
  "description": "Miscellaneous React+TypeScript utilities with support for full tree-shaking.",
6
6
  "private": false,
7
7
  "files": [
@@ -17,6 +17,7 @@
17
17
  "test:watch": "vitest",
18
18
  "ci": "npm run lint && npm run tsc && npm run build && npm run test",
19
19
  "ls": "lint-staged",
20
+ "clean": "rm -rf dist",
20
21
  "docs": "rm -rf docs && typedoc `find src -name '*.ts' ! -name '*.test.ts'` --out docs --name 'Chok’s React Utilities'",
21
22
  "gen-types": "tsc --emitDeclarationOnly --declaration",
22
23
  "gen-exports": "tsx scripts/gen-exports.ts",
package/sharedQuery.cjs CHANGED
@@ -214,7 +214,9 @@ var SharedState = class {
214
214
  options,
215
215
  SharedStateDefaults.storeExpiryMs
216
216
  );
217
- this.setValueBounded = (arg) => this.setValue(arg);
217
+ this.setValueBounded = (next) => {
218
+ this.setValue(next);
219
+ };
218
220
  pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
219
221
  const lazyLoad = options?.lazyLoad;
220
222
  const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateDefaults.lazyLoad;
@@ -319,24 +321,30 @@ function useDeepMemo(value) {
319
321
 
320
322
  // src/sharedQuery.ts
321
323
  var SharedQueryDefaults = {
322
- // Refetch on every page load.
324
+ /** Refetch on every page load. */
323
325
  staleMs: 0,
324
- // Keep in cache for a long time.
326
+ /** Keep in cache for a long time. */
325
327
  expiryMs: 30 * import_timeConstants2.MS_PER_DAY,
326
- // Always refetch automatically if the data is stale.
328
+ /** Always refetch automatically if the data is stale. */
327
329
  refetchOnStale: true,
328
- // No default persistence configured.
330
+ /** No default persistence configured. */
329
331
  persistTo: void 0,
330
- // 100 is not too large, but please tweak accordingly.
332
+ /** 100 is not too large, but please tweak accordingly. */
331
333
  maxSize: 100,
332
- // 100kb limit before discarding old records.
334
+ /** 100kb limit before discarding old records. */
333
335
  maxBytes: 1e5,
334
- // Default to log to console.
336
+ /** Default to log to console. */
335
337
  log: (...args) => console.log("[sharedQuery]", ...args)
336
338
  };
337
339
  var SharedQuery = class {
338
340
  inflightQueries = /* @__PURE__ */ new Map();
339
341
  queryState;
342
+ /**
343
+ * Keep track of the mounted query keys. This handles the case where an
344
+ * earlier-started query returns later, and when it returns, it was already
345
+ * unmounted, thus making it eligible for clean up right after fetch.
346
+ */
347
+ mountedKeys = /* @__PURE__ */ new Map();
340
348
  queryName;
341
349
  queryFn;
342
350
  expiryMs;
@@ -344,6 +352,7 @@ var SharedQuery = class {
344
352
  refetchOnStale;
345
353
  maxSize;
346
354
  maxBytes;
355
+ keepLastNonEmptyData;
347
356
  log;
348
357
  constructor(options) {
349
358
  this.queryName = options.queryName;
@@ -361,6 +370,7 @@ var SharedQuery = class {
361
370
  }
362
371
  this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
363
372
  this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
373
+ this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);
364
374
  this.log = options?.log ?? SharedQueryDefaults.log;
365
375
  this.queryState = sharedState(
366
376
  {},
@@ -523,6 +533,19 @@ var SharedQuery = class {
523
533
  }
524
534
  this.inflightQueries.clear();
525
535
  }
536
+ /** Keep track of mounted keys. */
537
+ mount(queryKey) {
538
+ this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);
539
+ }
540
+ /** Clean up mounted keys. */
541
+ unmount(queryKey) {
542
+ const count = this.mountedKeys.get(queryKey);
543
+ if (count === 1) {
544
+ this.mountedKeys.delete(queryKey);
545
+ } else {
546
+ this.mountedKeys.set(queryKey, (count ?? 0) - 1);
547
+ }
548
+ }
526
549
  /**
527
550
  * Discard the oldest entries if the size exceeds the limit. The last
528
551
  * remaining record cannot be deleted no matter what the limit is.
@@ -537,62 +560,48 @@ var SharedQuery = class {
537
560
  return;
538
561
  }
539
562
  const oldByteSize = (0, import_object_sizeof.default)(oldState);
540
- let newState = oldState;
563
+ let numToCut = this.maxSize ? oldSize - this.maxSize : 0;
564
+ let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;
565
+ if (numToCut <= 0 && bytesToCut <= 0) {
566
+ this.log(
567
+ `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
568
+ );
569
+ return;
570
+ }
571
+ this.log(
572
+ `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`
573
+ );
574
+ const newState = { ...oldState };
541
575
  let needUpdate = false;
542
- if (this.maxSize) {
543
- const numToCut = oldSize - this.maxSize;
544
- if (numToCut > 0) {
545
- this.log(
546
- `enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`
547
- );
548
- newState = { ...newState };
549
- needUpdate = true;
550
- Object.entries(newState).sort(
551
- (entry1, entry2) => (
552
- // Sort descending.
553
- entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs
554
- )
555
- ).slice(-numToCut).forEach(([key]) => {
556
- delete newState[key];
557
- });
576
+ const entriesByTimeAscending = Object.entries(newState).sort(
577
+ (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
578
+ );
579
+ for (const [key] of entriesByTimeAscending) {
580
+ if (this.mountedKeys.has(key)) {
581
+ this.log(`Cannot clean up ${key} as it is mounted`);
582
+ continue;
558
583
  }
559
- }
560
- if (this.maxBytes && Object.keys(newState).length > 1) {
561
- const currentByteSize = needUpdate ? (0, import_object_sizeof.default)(newState) : oldByteSize;
562
- let bytesToCut = currentByteSize - this.maxBytes;
563
- if (bytesToCut > 0) {
564
- this.log(
565
- `enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`
566
- );
567
- if (!needUpdate) {
568
- newState = { ...newState };
569
- needUpdate = true;
570
- }
571
- const entriesByTimeAscending = Object.entries(newState).sort(
572
- (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
573
- );
574
- for (
575
- let i = 0;
576
- // Leave the last record entriesByTimeAscending[len-1] untouched.
577
- i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
578
- i++
579
- ) {
580
- const key = entriesByTimeAscending[i][0];
581
- const thisSize = (0, import_object_sizeof.default)(key) + (0, import_object_sizeof.default)(newState[key]);
582
- delete newState[key];
583
- bytesToCut -= thisSize;
584
- }
584
+ const byteSize = (0, import_object_sizeof.default)(key) + (0, import_object_sizeof.default)(newState[key]);
585
+ this.log(
586
+ `Cleaning up unmounted ${key} (byteSize=${byteSize.toLocaleString()})`
587
+ );
588
+ numToCut--;
589
+ bytesToCut -= byteSize;
590
+ delete newState[key];
591
+ needUpdate = true;
592
+ if (numToCut <= 0 && bytesToCut <= 0) {
593
+ break;
585
594
  }
586
595
  }
587
- if (needUpdate) {
588
- this.queryState.setValue(newState);
596
+ if (!needUpdate) {
589
597
  this.log(
590
- `Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${(0, import_object_sizeof.default)(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`
598
+ `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
591
599
  );
592
600
  return;
593
601
  }
602
+ this.queryState.setValue(newState);
594
603
  this.log(
595
- `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
604
+ `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${(0, import_object_sizeof.default)(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`
596
605
  );
597
606
  }
598
607
  };
@@ -616,23 +625,29 @@ function useSharedQuery(query, args = []) {
616
625
  () => query.getQueryKey(stableArgs),
617
626
  [query, stableArgs]
618
627
  );
619
- const queryStateValue = queryState[queryKey];
628
+ const queryStateValue = queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;
620
629
  const setQueryStateValue = (0, import_react3.useCallback)(
621
- (arg) => {
630
+ (next) => {
622
631
  setQueryState((prev) => {
623
632
  const clone = { ...prev };
624
- clone[queryKey] = typeof arg === "function" ? arg(clone[queryKey]) : arg;
633
+ clone[queryKey] = typeof next === "function" ? next(clone[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY) : next;
625
634
  return clone;
626
635
  });
627
636
  },
628
637
  [queryKey, setQueryState]
629
638
  );
639
+ const [lastNonEmptyData, setLastNonEmptyData] = (0, import_react3.useState)();
640
+ (0, import_react3.useEffect)(() => {
641
+ if (query.keepLastNonEmptyData && queryStateValue.data !== void 0) {
642
+ setLastNonEmptyData(queryStateValue.data);
643
+ }
644
+ }, [query.keepLastNonEmptyData, queryStateValue.data]);
630
645
  const execute = (0, import_react3.useCallback)(
631
646
  async (forceRefresh) => {
632
647
  query.log(`Begin executing shared query ${queryKey}`);
633
648
  setQueryStateValue((prev) => ({
634
649
  // Keep old data and error.
635
- ...prev ?? {},
650
+ ...prev,
636
651
  lastUpdatedMs: Date.now(),
637
652
  loading: true
638
653
  }));
@@ -653,7 +668,7 @@ function useSharedQuery(query, args = []) {
653
668
  const now = Date.now();
654
669
  setQueryStateValue((prev) => ({
655
670
  // Keep old data.
656
- ...prev ?? {},
671
+ ...prev,
657
672
  lastUpdatedMs: now,
658
673
  loading: false,
659
674
  error: e,
@@ -666,15 +681,17 @@ function useSharedQuery(query, args = []) {
666
681
  );
667
682
  (0, import_react3.useEffect)(() => {
668
683
  isMounted.current = true;
684
+ query.mount(queryKey);
669
685
  void execute(false);
670
686
  return () => {
671
687
  isMounted.current = false;
688
+ query.unmount(queryKey);
672
689
  };
673
- }, [execute]);
690
+ }, [execute, query, queryKey]);
674
691
  return (0, import_react3.useMemo)(() => {
675
- const state = queryStateValue ?? DEFAULT_QUERY_STATE_ENTRY;
676
692
  return {
677
- ...state,
693
+ ...queryStateValue,
694
+ lastNonEmptyData,
678
695
  // This works only if the user-given queryFn supports abort. If not,
679
696
  // this function doesn't do anything, since we don't have any means to
680
697
  // abort the running queryFn.
@@ -689,7 +706,7 @@ function useSharedQuery(query, args = []) {
689
706
  setData: (data, dataUpdatedMs) => {
690
707
  const now = Date.now();
691
708
  setQueryStateValue((prev) => ({
692
- ...prev ?? {},
709
+ ...prev,
693
710
  data,
694
711
  dataUpdatedMs: dataUpdatedMs ?? now,
695
712
  lastUpdatedMs: now,
@@ -706,6 +723,7 @@ function useSharedQuery(query, args = []) {
706
723
  };
707
724
  }, [
708
725
  execute,
726
+ lastNonEmptyData,
709
727
  query,
710
728
  queryKey,
711
729
  queryStateValue,
package/sharedQuery.d.mts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { SharedStateOptions, SharedState } from './sharedState.mjs';
2
+ import 'react';
2
3
  import './utils/storage.mjs';
3
4
 
4
5
  /**
6
+ * @packageDocumentation
7
+ *
5
8
  * The "shared query" is a data fetching convenience library that helps users
6
9
  * to load data, check for staleness and expiration, cache in memory, local
7
10
  * storage or indexed db, and be able to manipulate the data anytime.
@@ -12,11 +15,81 @@ import './utils/storage.mjs';
12
15
  * records and the total byte size of data can be limited so as to ensure the
13
16
  * query does not take up too much memory.
14
17
  *
15
- * @packageDocumentation
18
+ * Example:
19
+ * ```
20
+ * import { sharedQuery, useSharedQuery } from "@choksheak/react-utils/sharedQuery";
21
+ *
22
+ * // Create a new shared query in the top level scope.
23
+ * // The query function `queryFn` here does not take any arguments.
24
+ * //
25
+ * // Only the queryName and queryFn are required and everything else is
26
+ * // optional.
27
+ * //
28
+ * // The queryName must be unique, otherwise we will throw an
29
+ * // error. This is because defining the same queryName for two different
30
+ * // queries implies that the queries are the same, thus violating the idea
31
+ * // of a "shared" query, and almost certainly points to a bug in the code.
32
+ * export const usersQuery = sharedQuery({
33
+ * queryName: "users",
34
+ * queryFn: listUsers,
35
+ * persistTo: "indexedDb",
36
+ * staleMs: MS_PER_DAY,
37
+ * });
38
+ *
39
+ * // Note that the query will only start fetching on mount in React.
40
+ * // But you can do `usersQuery.getCachedOrFetch()` here to prefetch the
41
+ * // data if necessary.
42
+ * usersQuery.getCachedOrFetch();
43
+ *
44
+ * // Example of using a query function with arguments.
45
+ * export const getUserQuery = sharedQuery({
46
+ * queryName: "getUser",
47
+ * queryFn: getUser, // getUser(userId: string) => User
48
+ * persistTo: "indexedDb",
49
+ * staleMs: MS_PER_DAY,
50
+ * });
51
+ *
52
+ * export const UsersComponent: React.FC = () => {
53
+ * // Second argument is optional, and is an array of arguments to be
54
+ * // passed to the `queryFn`.
55
+ * const users = useSharedQuery(usersQuery);
56
+ *
57
+ * return (
58
+ * <>
59
+ * <h1>List of Users</h1>
60
+ *
61
+ * <button onclick={users.refetch}>Refresh</button>
62
+ *
63
+ * {users.loading && <Loader />}
64
+ *
65
+ * {users.error && !users.data && <p>Error: {String(users.error)}</p>}
66
+ *
67
+ * <p>Data: {users.data ? JSON.stringify(users.data) : "-"}</p>
68
+ * </>
69
+ * );
70
+ * };
71
+ *
72
+ * export const AnotherComponent: React.FC = () => {
73
+ * // This query will be deduped with the one above, sharing the same
74
+ * // data and the same fetches.
75
+ * const users = useSharedQuery(usersQuery);
76
+ * ...
77
+ * };
78
+ *
79
+ * export const DisplayOneUser: React.FC<{userId: string}> = ({userId}) => {
80
+ * // Example of using the query with an argument. Whenever the argument
81
+ * // changes, the data will be fetched and updated automatically.
82
+ * const user = useSharedQuery(getUserQuery, [userId]);
83
+ *
84
+ * return <>User: {user.data ? JSON.stringify(user.data) : "-"}</>;
85
+ * };
86
+ * ```
16
87
  */
17
88
 
89
+ /** Type of the queryFn used to fetch the data from source. */
18
90
  type QueryFn<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData> | TData;
19
- type QueryStateValue<TData> = {
91
+ /** An entry for a persisted state corresponding to one query key. */
92
+ type QueryStateValue<TData> = Readonly<{
20
93
  /**
21
94
  * When this record was last updated. Used for LRU determination. If you want
22
95
  * the data age, please use `dataUpdatedMs` instead.
@@ -32,8 +105,14 @@ type QueryStateValue<TData> = {
32
105
  */
33
106
  error?: unknown;
34
107
  errorUpdatedMs?: number;
35
- };
36
- type UseQueryResult<TData> = QueryStateValue<TData> & {
108
+ }>;
109
+ /** Return type for useSharedQuery(). */
110
+ type UseQueryResult<TData> = QueryStateValue<TData> & Readonly<{
111
+ /**
112
+ * Return the last non-empty data if you want to display the last loaded data
113
+ * while waiting for the new data to load.
114
+ */
115
+ lastNonEmptyData: TData | undefined;
37
116
  /**
38
117
  * Cancels the inflight query (if any). Returns true if canceled.
39
118
  */
@@ -48,37 +127,93 @@ type UseQueryResult<TData> = QueryStateValue<TData> & {
48
127
  */
49
128
  setData: (data: TData, dataUpdatedMs?: number) => void;
50
129
  deleteData: () => void;
51
- };
130
+ }>;
131
+ /** List of all available persistence options. */
52
132
  type PersistTo = "localStorage" | "indexedDb";
53
133
  /**
54
134
  * The full query state is a record of all the fetch states mapped by
55
135
  * query key to the state entry.
56
136
  */
57
137
  type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;
138
+ /** Options to configure a shared query. */
58
139
  type SharedQueryOptions<TArgs extends unknown[], TData> = {
140
+ /**
141
+ * The name could have been auto-generated, but we let the user give us a
142
+ * human-readable name that can be identified quickly in the logs.
143
+ * Note that the queryKey is a string formed by the queryName plus the query
144
+ * arguments.
145
+ */
59
146
  queryName: string;
147
+ /**
148
+ * Function to fetch data.
149
+ */
60
150
  queryFn: QueryFn<TArgs, TData>;
151
+ /**
152
+ * ms before data considered stale; 0 means always stale.
153
+ */
61
154
  staleMs?: number;
155
+ /**
156
+ * ms before data is removed from cache; 0 means never expire.
157
+ */
62
158
  expiryMs?: number;
159
+ /**
160
+ * Trigger background re-fetch if stale.
161
+ */
63
162
  refetchOnStale?: boolean;
163
+ /**
164
+ * Shortcut to set the localStorageKey or indexedDbKey automatically from
165
+ * queryName. This is important because it avoids requiring the user to
166
+ * specify the same queryName twice everytime.
167
+ */
64
168
  persistTo?: PersistTo;
169
+ /**
170
+ * Max number of entries to keep. The oldest entries will be discarded.
171
+ * The last record cannot be discarded. 0 means no limit.
172
+ */
65
173
  maxSize?: number;
174
+ /**
175
+ * Max number of bytes to keep. The oldest entries will be discarded.
176
+ * The last record cannot be discarded. 0 means no limit.
177
+ */
66
178
  maxBytes?: number;
179
+ /**
180
+ * Keep a copy of the last non-empty data so that when you change the
181
+ * query arguments, you can still display the old data while waiting for the
182
+ * new data to load.
183
+ */
184
+ keepLastNonEmptyData?: boolean;
185
+ /**
186
+ * Customize the logger.
187
+ */
67
188
  log?: (...args: unknown[]) => void;
68
189
  } & SharedStateOptions<SharedQueryState<TData>>;
69
190
  /** Users can override these values globally. */
70
191
  declare const SharedQueryDefaults: {
192
+ /** Refetch on every page load. */
71
193
  staleMs: number;
194
+ /** Keep in cache for a long time. */
72
195
  expiryMs: number;
196
+ /** Always refetch automatically if the data is stale. */
73
197
  refetchOnStale: boolean;
198
+ /** No default persistence configured. */
74
199
  persistTo: PersistTo | undefined;
200
+ /** 100 is not too large, but please tweak accordingly. */
75
201
  maxSize: number;
202
+ /** 100kb limit before discarding old records. */
76
203
  maxBytes: number;
204
+ /** Default to log to console. */
77
205
  log: (...args: unknown[]) => void;
78
206
  };
207
+ /** Please use sharedQuery() instead. */
79
208
  declare class SharedQuery<TArgs extends unknown[], TData> {
80
209
  private readonly inflightQueries;
81
210
  readonly queryState: SharedState<SharedQueryState<TData>>;
211
+ /**
212
+ * Keep track of the mounted query keys. This handles the case where an
213
+ * earlier-started query returns later, and when it returns, it was already
214
+ * unmounted, thus making it eligible for clean up right after fetch.
215
+ */
216
+ private readonly mountedKeys;
82
217
  readonly queryName: string;
83
218
  private readonly queryFn;
84
219
  readonly expiryMs: number;
@@ -86,6 +221,7 @@ declare class SharedQuery<TArgs extends unknown[], TData> {
86
221
  readonly refetchOnStale: boolean;
87
222
  readonly maxSize: number;
88
223
  readonly maxBytes: number;
224
+ readonly keepLastNonEmptyData: boolean;
89
225
  readonly log: (...args: unknown[]) => void;
90
226
  constructor(options: SharedQueryOptions<TArgs, TData>);
91
227
  /** Returns a key to identify requests based on the given args. */
@@ -146,6 +282,10 @@ declare class SharedQuery<TArgs extends unknown[], TData> {
146
282
  deleteData(queryKey: string): void;
147
283
  /** Delete all currently cached data & all inflight promises. */
148
284
  clear(): void;
285
+ /** Keep track of mounted keys. */
286
+ mount(queryKey: string): void;
287
+ /** Clean up mounted keys. */
288
+ unmount(queryKey: string): void;
149
289
  /**
150
290
  * Discard the oldest entries if the size exceeds the limit. The last
151
291
  * remaining record cannot be deleted no matter what the limit is.