From 1f29629a8d4e053546f08c089739f205ee7bbc05 Mon Sep 17 00:00:00 2001 From: Michael Rochester Date: Mon, 6 Mar 2023 17:55:42 +0000 Subject: [PATCH] feat(compute-key): add support for mapKeyToCacheKey (#71) this option allows consumers to directly vary their cache key --- README.md | 44 ++++++++++++-- packages/fetchye/__tests__/computeKey.spec.js | 59 +++++++++++++++++++ packages/fetchye/src/computeKey.js | 22 +++++-- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3519384..1dfae26 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,37 @@ const BookList = ({ genre }) => { }; ``` +### Controlling the Cache Key + +By passing mapKeyToCacheKey as an option you can customize the cacheKey without affecting the key. This allows you to control the cacheKey directly to enable advanced behaviour in your cache. + +Note: This option can lead to unexpected behaviour in many cases. Customizing the cacheKey in this way could lead to accidental collisions that lead to fetchye providing the 'wrong' cache for some of your calls, or unnecessary cache-misses causing significant performance degradation. + +In this example the client can dynamically switch between http and https depending on the needs of the user, but should keep the same cache key. + +Therefore, mapKeyToCacheKey is defined to transform the url to always have the same protocol in the cacheKey. + +```jsx +import React from 'react'; +import { useFetchye } from 'fetchye'; + +const BookList = ({ ssl }) => { + const { isLoading, data } = useFetchye(`${ssl ? 'https' : 'http'}://example.com/api/books/`, + { + mapKeyToCacheKey: (key) => key.replace('https://', 'http://'), + } + ); + + if (isLoading) { + return (

Loading...

); + } + + return ( + {/* Render data */} + ); +}; +``` + ### SSR #### One App SSR @@ -717,12 +748,13 @@ const { isLoading, data, error, run } = useFetchye(key, { defer: Boolean, mapOpt **Options** -| name | type | required | description | -|---|---|---|---| -| `mapOptionsToKey` | `(options: Options) => transformedOptions` | `false` | A function that maps options to the key that will become part of the cache key | -| `defer` | `Boolean` | `false` | Prevents execution of `useFetchye` on each render in favor of using the returned `run` function. *Defaults to `false`* | -| `initialData` | `Object` | `false` | Seeds the initial data on first render of `useFetchye` to accomodate server side rendering *Defaults to `undefined`* | -| `...restOptions` | `ES6FetchOptions` | `true` | Contains any ES6 Compatible `fetch` option. (See [Fetch Options](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Supplying_request_options)) | +| name | type | required | description | +|--------------------|-------------------------------------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mapOptionsToKey` | `(options: Options) => transformedOptions` | `false` | A function that maps options to the key that will become part of the cache key | +| `mapKeyToCacheKey` | `(key: String, options: Options) => cacheKey: String` | `false` | A function that maps the key for use as the cacheKey allowing direct control of the cacheKey | +| `defer` | `Boolean` | `false` | Prevents execution of `useFetchye` on each render in favor of using the returned `run` function. *Defaults to `false`* | +| `initialData` | `Object` | `false` | Seeds the initial data on first render of `useFetchye` to accomodate server side rendering *Defaults to `undefined`* | +| `...restOptions` | `ES6FetchOptions` | `true` | Contains any ES6 Compatible `fetch` option. (See [Fetch Options](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Supplying_request_options)) | **Returns** diff --git a/packages/fetchye/__tests__/computeKey.spec.js b/packages/fetchye/__tests__/computeKey.spec.js index d26d96a..c7b934f 100644 --- a/packages/fetchye/__tests__/computeKey.spec.js +++ b/packages/fetchye/__tests__/computeKey.spec.js @@ -14,9 +14,18 @@ * permissions and limitations under the License. */ +import computeHash from 'object-hash'; import { computeKey } from '../src/computeKey'; +jest.mock('object-hash', () => { + const originalComputeHash = jest.requireActual('object-hash'); + return jest.fn(originalComputeHash); +}); + describe('computeKey', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('should return an object', () => { expect(computeKey('abcd', {})).toMatchInlineSnapshot(` Object { @@ -58,4 +67,54 @@ describe('computeKey', () => { }; expect(computeKey('uri', firstOptions).hash).toBe(computeKey('uri', secondOptions).hash); }); + + it('should return a different, stable hash, if the option mapKeyToCacheKey is passed', () => { + const key = 'abcd'; + const mappedKey = 'efgh'; + const { hash: mappedHash1 } = computeKey(key, { mapKeyToCacheKey: () => mappedKey }); + const { hash: mappedHash2 } = computeKey(key, { mapKeyToCacheKey: () => mappedKey }); + + const { hash: unmappedHash } = computeKey(key, {}); + + expect(mappedHash1).toBe(mappedHash2); + expect(mappedHash1).not.toBe(unmappedHash); + }); + + it('should return the same key if the option mapKeyToCacheKey returns the same string as the key', () => { + const key = 'abcd'; + const { hash: mappedHash } = computeKey(key, { mapKeyToCacheKey: (_key) => _key }); + + const { hash: unmappedHash } = computeKey(key, {}); + + expect(mappedHash).toBe(unmappedHash); + }); + + it('should pass generated cacheKey to the underlying hash function along with the options, and return the un-mapped key to the caller', () => { + const computedKey = computeKey(() => 'abcd', { + mapKeyToCacheKey: (key, options) => `${key.toUpperCase()}-${options.optionKeyMock}`, + optionKeyMock: 'optionKeyValue', + }); + expect(computedKey.key).toBe('abcd'); + expect(computeHash).toHaveBeenCalledWith(['ABCD-optionKeyValue', { optionKeyMock: 'optionKeyValue' }], { respectType: false }); + }); + + it('should return false if mapKeyToCacheKey throws error', () => { + expect( + computeKey(() => 'abcd', { + mapKeyToCacheKey: () => { + throw new Error('error'); + }, + }) + ).toEqual(false); + }); + + it('should return false if mapKeyToCacheKey returns false', () => { + expect(computeKey(() => 'abcd', { mapKeyToCacheKey: () => false })).toEqual(false); + }); + + it('should throw an error if mapKeyToCacheKey is defined and not a function', () => { + expect(() => computeKey(() => 'abcd', + { mapKeyToCacheKey: 'string' } + )).toThrow('mapKeyToCacheKey must be a function'); + }); }); diff --git a/packages/fetchye/src/computeKey.js b/packages/fetchye/src/computeKey.js index 0f74e99..ee20d89 100644 --- a/packages/fetchye/src/computeKey.js +++ b/packages/fetchye/src/computeKey.js @@ -18,14 +18,14 @@ import computeHash from 'object-hash'; import mapHeaderNamesToLowerCase from './mapHeaderNamesToLowerCase'; export const computeKey = (key, options) => { - const { headers, ...restOfOptions } = options; + const { headers, mapKeyToCacheKey, ...restOfOptions } = options; const nextOptions = { ...restOfOptions }; if (headers) { nextOptions.headers = mapHeaderNamesToLowerCase(headers); } + let nextKey = key; if (typeof key === 'function') { - let nextKey; try { nextKey = key(nextOptions); } catch (error) { @@ -34,9 +34,23 @@ export const computeKey = (key, options) => { if (!nextKey) { return false; } - return { key: nextKey, hash: computeHash([nextKey, nextOptions], { respectType: false }) }; } - return { key, hash: computeHash([key, nextOptions], { respectType: false }) }; + + let cacheKey = nextKey; + if (mapKeyToCacheKey !== undefined && typeof mapKeyToCacheKey === 'function') { + try { + cacheKey = mapKeyToCacheKey(nextKey, nextOptions); + } catch (error) { + return false; + } + if (!cacheKey) { + return false; + } + } else if (mapKeyToCacheKey !== undefined) { + throw new TypeError('mapKeyToCacheKey must be a function'); + } + + return { key: nextKey, hash: computeHash([cacheKey, nextOptions], { respectType: false }) }; }; export default computeKey;