diff --git a/.editorconfig b/.editorconfig index 2001870..e55bc07 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,3 @@ -# http://editorconfig.org - root = true [*] @@ -7,5 +5,6 @@ charset = utf-8 end_of_line = lf indent_style = space indent_size = 2 +max_line_length = 80 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 5fb8928..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -!.* -node_modules -/universal -/server -/test -tap-snapshots diff --git a/.eslintrc.json b/.eslintrc.json index 3c8f107..4b145a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,31 @@ { - "extends": ["env"] + "extends": ["eslint:recommended", "plugin:react-hooks/recommended"], + "env": { + "es2022": true, + "node": true, + "browser": true + }, + "parserOptions": { + "ecmaVersion": "latest" + }, + "plugins": ["simple-import-sort"], + "rules": { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error" + }, + "overrides": [ + { + "files": ["*.mjs"], + "parserOptions": { + "sourceType": "module" + }, + "globals": { + "__dirname": "off", + "__filename": "off", + "exports": "off", + "module": "off", + "require": "off" + } + } + ] } diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..4c5a7a0 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1 @@ +github: jaydenseric diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02ff57..5b8cba6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ name: CI -on: [push] +on: [push, workflow_dispatch] jobs: test: name: Test with Node.js v${{ matrix.node }} and ${{ matrix.os }} @@ -7,16 +7,12 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - node: ['8', '10', '12', '13'] + node: ["18", "20", "22"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup Node.js v${{ matrix.node }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: npm install and test - run: | - npm install - npm test - env: - CI: true + run: npm install-test diff --git a/.gitignore b/.gitignore index 141ad5d..fd4f2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,2 @@ node_modules -npm-debug.log .DS_Store -/universal -/server -/test -.nyc_output diff --git a/.huskyrc.json b/.huskyrc.json deleted file mode 100644 index 4d077c8..0000000 --- a/.huskyrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "hooks": { - "pre-commit": "lint-staged" - } -} diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index 9f9bd85..0000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "*.{mjs,js}": "eslint", - "*.{json,yml,md}": "prettier -l" -} diff --git a/.prettierignore b/.prettierignore index f6b7fdb..ec6d3cd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1 @@ package.json -.nyc_output -tap-snapshots diff --git a/.prettierrc.json b/.prettierrc.json index b4ef012..d2504b4 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,3 @@ { - "proseWrap": "never", - "singleQuote": true, - "semi": false + "proseWrap": "never" } diff --git a/.size-limit.json b/.size-limit.json deleted file mode 100644 index 81295b8..0000000 --- a/.size-limit.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "name": "Server", - "path": "size-limit-entries/server.mjs", - "limit": "2.5 KB", - "ignore": ["prop-types"] - }, - { - "name": "Browser", - "path": "size-limit-entries/browser.mjs", - "limit": "2.5 KB", - "ignore": ["prop-types", "object-assign"] - } -] diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..709c196 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "typescript.disableAutomaticTypeAcquisition": true, + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/Cache.mjs b/Cache.mjs new file mode 100644 index 0000000..728657a --- /dev/null +++ b/Cache.mjs @@ -0,0 +1,70 @@ +// @ts-check + +/** + * Cache store. + * @see {@link CacheEventMap `CacheEventMap`} for a map of possible events. + */ +export default class Cache extends EventTarget { + /** + * @param {CacheStore} [store] Initial {@link Cache.store cache store}. + * Defaults to `{}`. Useful for hydrating cache data from a server side + * render prior to the initial client side render. + */ + constructor(store = {}) { + super(); + + if (typeof store !== "object" || !store || Array.isArray(store)) + throw new TypeError("Constructor argument 1 `store` must be an object."); + + /** + * Store of cache {@link CacheKey keys} and associated + * {@link CacheValue values}. + * @type {CacheStore} + */ + this.store = store; + } +} + +/** + * Map of possible {@linkcode Cache} events. Note that the keys don’t match the + * dispatched event names that dynamically contain the associated + * {@link CacheKey cache key}. + * @typedef {object} CacheEventMap + * @prop {CustomEvent} set Signals that a + * {@link Cache.store cache store} entry was set. The event name starts with + * the {@link CacheKey cache key} of the set entry, followed by `/set`. + * @prop {CustomEvent} stale Signals that a {@link Cache.store cache store} + * entry is now stale (often due to a mutation) and should probably be + * reloaded. The event name starts with the + * {@link CacheKey cache key} of the stale entry, followed by `/stale`. + * @prop {CustomEvent} prune Signals that a {@link Cache.store cache store} + * entry will be deleted unless the event is canceled via + * `event.preventDefault()`. The event name starts with the + * {@link CacheKey cache key} of the entry being pruned, followed by `/prune`. + * @prop {CustomEvent} delete Signals that a {@link Cache.store cache store} + * entry was deleted. The event name starts with the + * {@link CacheKey cache key} of the deleted entry, followed by `/delete`. + */ + +/** + * @typedef {object} CacheEventSetDetail + * @prop {CacheValue} cacheValue The set {@link CacheValue cache value}. + */ + +/** + * Unique key to access a {@link CacheValue cache value}. + * @typedef {string} CacheKey + */ + +/** + * {@link Cache.store Cache store} value. If server side rendering, it should + * be JSON serializable for client hydration. It should contain information + * about any errors that occurred during loading so they can be rendered, and if + * server side rendering, be hydrated on the client. + * @typedef {unknown} CacheValue + */ + +/** + * Cache store. + * @typedef {{ [cacheKey: CacheKey]: CacheValue }} CacheStore + */ diff --git a/Cache.test.mjs b/Cache.test.mjs new file mode 100644 index 0000000..6783496 --- /dev/null +++ b/Cache.test.mjs @@ -0,0 +1,70 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Class `Cache`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./Cache.mjs", import.meta.url), 200); + }); + + it("Constructor argument 1 `store` not an object.", () => { + throws(() => { + new Cache( + // @ts-expect-error Testing invalid. + null, + ); + }, new TypeError("Constructor argument 1 `store` must be an object.")); + }); + + it("Constructor argument 1 `store` missing", () => { + const cache = new Cache(); + + deepStrictEqual(cache.store, {}); + }); + + it("Constructor argument 1 `store` an object.", () => { + const initialStore = { + a: 1, + b: 2, + }; + const cache = new Cache({ ...initialStore }); + + deepStrictEqual(cache.store, initialStore); + }); + + it("Events.", () => { + const cache = new Cache(); + + assertInstanceOf(cache, EventTarget); + + /** @type {Event | null} */ + let listenedEvent = null; + + /** @type {EventListener} */ + const listener = (event) => { + listenedEvent = event; + }; + + const eventName = "a"; + const event = new CustomEvent(eventName); + + cache.addEventListener(eventName, listener); + cache.dispatchEvent(event); + + strictEqual(listenedEvent, event); + + listenedEvent = null; + + cache.removeEventListener(eventName, listener); + cache.dispatchEvent(new CustomEvent(eventName)); + + strictEqual(listenedEvent, null); + }); +}); diff --git a/CacheContext.mjs b/CacheContext.mjs new file mode 100644 index 0000000..604de73 --- /dev/null +++ b/CacheContext.mjs @@ -0,0 +1,18 @@ +// @ts-check + +/** @import Cache from "./Cache.mjs" */ + +import React from "react"; + +/** + * [React context](https://reactjs.org/docs/context.html) for a + * {@linkcode Cache} instance. + * @type {React.Context} + */ +const CacheContext = React.createContext( + /** @type {Cache | undefined} */ (undefined), +); + +CacheContext.displayName = "CacheContext"; + +export default CacheContext; diff --git a/CacheContext.test.mjs b/CacheContext.test.mjs new file mode 100644 index 0000000..16673f7 --- /dev/null +++ b/CacheContext.test.mjs @@ -0,0 +1,38 @@ +// @ts-check + +import { strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; + +describe("React context `CacheContext`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./CacheContext.mjs", import.meta.url), 120); + }); + + it("Used as a React context.", () => { + let contextValue; + + function TestComponent() { + contextValue = React.useContext(CacheContext); + return null; + } + + const value = new Cache(); + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value }, + React.createElement(TestComponent), + ), + ); + + strictEqual(contextValue, value); + }); +}); diff --git a/HYDRATION_TIME_MS.mjs b/HYDRATION_TIME_MS.mjs new file mode 100644 index 0000000..5d73e53 --- /dev/null +++ b/HYDRATION_TIME_MS.mjs @@ -0,0 +1,10 @@ +// @ts-check + +/** @import useAutoLoad from "./useAutoLoad.mjs" */ + +/** + * Number of milliseconds after the first client render that’s considered the + * hydration time; during which the {@linkcode useAutoLoad} React hook won’t + * load if the cache entry is already populated. + */ +export default 1000; diff --git a/HYDRATION_TIME_MS.test.mjs b/HYDRATION_TIME_MS.test.mjs new file mode 100644 index 0000000..646c175 --- /dev/null +++ b/HYDRATION_TIME_MS.test.mjs @@ -0,0 +1,20 @@ +// @ts-check + +import { strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import HYDRATION_TIME_MS from "./HYDRATION_TIME_MS.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; + +describe("Constant `HYDRATION_TIME_MS`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./HYDRATION_TIME_MS.mjs", import.meta.url), + 65, + ); + }); + + it("Value.", () => { + strictEqual(HYDRATION_TIME_MS, 1000); + }); +}); diff --git a/HydrationTimeStampContext.mjs b/HydrationTimeStampContext.mjs new file mode 100644 index 0000000..e9cb6f5 --- /dev/null +++ b/HydrationTimeStampContext.mjs @@ -0,0 +1,16 @@ +// @ts-check + +import React from "react"; + +/** + * [React context](https://reactjs.org/docs/context.html) for the client side + * hydration {@link DOMHighResTimeStamp time stamp}. + * @type {React.Context} + */ +const HydrationTimeStampContext = React.createContext( + /** @type {DOMHighResTimeStamp | undefined} */ (undefined), +); + +HydrationTimeStampContext.displayName = "HydrationTimeStampContext"; + +export default HydrationTimeStampContext; diff --git a/HydrationTimeStampContext.test.mjs b/HydrationTimeStampContext.test.mjs new file mode 100644 index 0000000..918c351 --- /dev/null +++ b/HydrationTimeStampContext.test.mjs @@ -0,0 +1,44 @@ +// @ts-check + +import { strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; + +import HydrationTimeStampContext from "./HydrationTimeStampContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; + +describe( + "React context `HydrationTimeStampContext`.", + { concurrency: true }, + () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./HydrationTimeStampContext.mjs", import.meta.url), + 150, + ); + }); + + it("Used as a React context.", () => { + let contextValue; + + function TestComponent() { + contextValue = React.useContext(HydrationTimeStampContext); + return null; + } + + const value = 1; + + createReactTestRenderer( + React.createElement( + HydrationTimeStampContext.Provider, + { value }, + React.createElement(TestComponent), + ), + ); + + strictEqual(contextValue, value); + }); + }, +); diff --git a/Loading.mjs b/Loading.mjs new file mode 100644 index 0000000..5357a34 --- /dev/null +++ b/Loading.mjs @@ -0,0 +1,49 @@ +// @ts-check + +/** + * @import { CacheKey, CacheValue } from "./Cache.mjs" + * @import LoadingCacheValue from "./LoadingCacheValue.mjs" + */ + +/** + * Loading store. + * @see {@link LoadingEventMap `LoadingEventMap`} for a map of possible events. + */ +export default class Loading extends EventTarget { + constructor() { + super(); + + /** + * Store of loading {@link CacheKey cache keys} and associated + * {@link LoadingCacheValue loading cache values}. Multiple for the same key + * are set in the order loading started. + * @type {{ [cacheKey: CacheKey]: Set }} + */ + this.store = {}; + } +} + +/** + * Map of possible {@linkcode Loading} events. Note that the keys don’t match + * the dispatched event names that dynamically contain the associated + * {@link CacheKey cache key}. + * @typedef {object} LoadingEventMap + * @prop {CustomEvent} start Signals the start of + * {@link LoadingCacheValue loading a cache value}. The event name starts with + * the {@link CacheKey cache key}, followed by `/start`. + * @prop {CustomEvent} end Signals the end of + * {@link LoadingCacheValue loading a cache value}; either the loading + * finished and the {@link CacheValue cache value} was set, the loading was + * aborted, or there was an error. The event name starts with the + * {@link CacheKey cache key}, followed by `/end`. + */ + +/** + * @typedef {object} LoadingEventStartDetail + * @prop {LoadingCacheValue} loadingCacheValue Loading cache value that started. + */ + +/** + * @typedef {object} LoadingEventEndDetail + * @prop {LoadingCacheValue} loadingCacheValue Loading cache value that ended. + */ diff --git a/Loading.test.mjs b/Loading.test.mjs new file mode 100644 index 0000000..39abfe2 --- /dev/null +++ b/Loading.test.mjs @@ -0,0 +1,51 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import Loading from "./Loading.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Class `Loading`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./Loading.mjs", import.meta.url), 120); + }); + + it("Constructor.", () => { + const loading = new Loading(); + + deepStrictEqual(loading.store, {}); + }); + + it("Events.", () => { + const loading = new Loading(); + + assertInstanceOf(loading, EventTarget); + + /** @type {Event | null} */ + let listenedEvent = null; + + /** @type {EventListener} */ + const listener = (event) => { + listenedEvent = event; + }; + + const eventName = "a"; + const event = new CustomEvent(eventName); + + loading.addEventListener(eventName, listener); + loading.dispatchEvent(event); + + strictEqual(listenedEvent, event); + + listenedEvent = null; + + loading.removeEventListener(eventName, listener); + loading.dispatchEvent(new CustomEvent(eventName)); + + strictEqual(listenedEvent, null); + }); +}); diff --git a/LoadingCacheValue.mjs b/LoadingCacheValue.mjs new file mode 100644 index 0000000..e604446 --- /dev/null +++ b/LoadingCacheValue.mjs @@ -0,0 +1,140 @@ +// @ts-check + +/** + * @import { CacheEventMap, CacheKey, CacheValue } from "./Cache.mjs" + * @import { LoadingEventMap } from "./Loading.mjs" + */ + +import Cache from "./Cache.mjs"; +import cacheEntrySet from "./cacheEntrySet.mjs"; +import Loading from "./Loading.mjs"; + +/** + * Controls loading a {@link CacheValue cache value}. It dispatches this + * sequence of events: + * + * 1. {@linkcode Loading} event {@link LoadingEventMap.start `start`}. + * 2. {@linkcode Cache} event {@link CacheEventMap.set `set`}. + * 3. {@linkcode Loading} event {@link LoadingEventMap.end `end`}. + */ +export default class LoadingCacheValue { + /** + * @param {Loading} loading Loading to update. + * @param {Cache} cache Cache to update. + * @param {CacheKey} cacheKey Cache key. + * @param {Promise} loadingResult Resolves the loading result + * (including any loading errors) to be set as the + * {@link CacheValue cache value} if loading isn’t aborted. Shouldn’t + * reject. + * @param {AbortController} abortController Aborts this loading and skips + * setting the loading result as the {@link CacheValue cache value}. Has no + * effect after loading ends. + */ + constructor(loading, cache, cacheKey, loadingResult, abortController) { + if (!(loading instanceof Loading)) + throw new TypeError("Argument 1 `loading` must be a `Loading` instance."); + + if (!(cache instanceof Cache)) + throw new TypeError("Argument 2 `cache` must be a `Cache` instance."); + + if (typeof cacheKey !== "string") + throw new TypeError("Argument 3 `cacheKey` must be a string."); + + if (!(loadingResult instanceof Promise)) + throw new TypeError( + "Argument 4 `loadingResult` must be a `Promise` instance.", + ); + + if (!(abortController instanceof AbortController)) + throw new TypeError( + "Argument 5 `abortController` must be an `AbortController` instance.", + ); + + /** + * When this loading started. + * @type {DOMHighResTimeStamp} + */ + this.timeStamp = performance.now(); + + /** + * Aborts this loading and skips setting the loading result as the + * {@link CacheValue cache value}. Has no effect after loading ends. + * @type {AbortController} + */ + this.abortController = abortController; + + if (!(cacheKey in loading.store)) loading.store[cacheKey] = new Set(); + + const loadingSet = loading.store[cacheKey]; + + // In this constructor the instance must be synchronously added to the cache + // key’s loading set, so instances are set in the order they’re constructed + // and the loading store is updated for sync code following construction of + // a new instance. + + /** @type {((value?: unknown) => void) | undefined} */ + let loadingAddedResolve; + + const loadingAdded = new Promise((resolve) => { + loadingAddedResolve = resolve; + }); + + /** + * Resolves the loading result, after the {@link CacheValue cache value} has + * been set if the loading wasn’t aborted. Shouldn’t reject. + * @type {Promise} + */ + this.promise = loadingResult.then(async (result) => { + await loadingAdded; + + if ( + // The loading wasn’t aborted. + !this.abortController.signal.aborted + ) { + // Before setting the cache value, await any earlier loading for the + // same cache key to to ensure events are emitted in order and that the + // last loading sets the final cache value. + + let previousPromise; + + for (const loadingCacheValue of loadingSet.values()) { + if (loadingCacheValue === this) { + // Harmless to await if it doesn’t exist. + await previousPromise; + break; + } + + previousPromise = loadingCacheValue.promise; + } + + cacheEntrySet(cache, cacheKey, result); + } + + loadingSet.delete(this); + + if (!loadingSet.size) delete loading.store[cacheKey]; + + loading.dispatchEvent( + new CustomEvent(`${cacheKey}/end`, { + detail: { + loadingCacheValue: this, + }, + }), + ); + + return result; + }); + + loadingSet.add(this); + + /** @type {(value?: unknown) => void} */ (loadingAddedResolve)(); + + loading.dispatchEvent( + new CustomEvent(`${cacheKey}/start`, { + detail: { + loadingCacheValue: this, + }, + }), + ); + } +} diff --git a/LoadingCacheValue.test.mjs b/LoadingCacheValue.test.mjs new file mode 100644 index 0000000..97b1aaa --- /dev/null +++ b/LoadingCacheValue.test.mjs @@ -0,0 +1,571 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; +import assertTypeOf from "./test/assertTypeOf.mjs"; +import Deferred from "./test/Deferred.mjs"; + +describe("Class `LoadingCacheValue`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./LoadingCacheValue.mjs", import.meta.url), + 650, + ); + }); + + it("Constructor argument 1 `loading` not a `Loading` instance.", () => { + throws(() => { + new LoadingCacheValue( + // @ts-expect-error Testing invalid. + true, + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ); + }, new TypeError("Argument 1 `loading` must be a `Loading` instance.")); + }); + + it("Constructor argument 2 `cache` not a `Cache` instance.", () => { + throws(() => { + new LoadingCacheValue( + new Loading(), + // @ts-expect-error Testing invalid. + true, + "a", + Promise.resolve(), + new AbortController(), + ); + }, new TypeError("Argument 2 `cache` must be a `Cache` instance.")); + }); + + it("Constructor argument 3 `cacheKey` not a string.", () => { + throws(() => { + new LoadingCacheValue( + new Loading(), + new Cache(), + // @ts-expect-error Testing invalid. + true, + Promise.resolve(), + new AbortController(), + ); + }, new TypeError("Argument 3 `cacheKey` must be a string.")); + }); + + it("Constructor argument 4 `loadingResult` not a `Promise` instance.", () => { + throws(() => { + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + // @ts-expect-error Testing invalid. + true, + new AbortController(), + ); + }, new TypeError("Argument 4 `loadingResult` must be a `Promise` instance.")); + }); + + it("Constructor argument 5 `abortController` not an `AbortController` instance.", () => { + throws(() => { + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 5 `abortController` must be an `AbortController` instance.")); + }); + + it("Construction, single loading.", async () => { + const cacheKey = "a"; + const cache = new Cache(); + const loading = new Loading(); + + /** @type {Array<{ for: "cache" | "loading", event: Event }>} */ + let events = []; + + cache.addEventListener(`${cacheKey}/set`, (event) => { + events.push({ for: "cache", event }); + }); + + /** @type {EventListener} */ + const loadingListener = (event) => { + events.push({ for: "loading", event }); + }; + + loading.addEventListener(`${cacheKey}/start`, loadingListener); + loading.addEventListener(`${cacheKey}/end`, loadingListener); + + const { promise: loadingResult, resolve: loadingResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + const abortController = new AbortController(); + const loadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + loadingResult, + abortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { loadingCacheValue }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([loadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(loadingCacheValue.timeStamp, "number"); + strictEqual(performance.now() - loadingCacheValue.timeStamp < 50, true); + strictEqual(loadingCacheValue.abortController, abortController); + assertInstanceOf(loadingCacheValue.promise, Promise); + + events = []; + + const cacheValue = Object.freeze({}); + + loadingResultResolve(cacheValue); + + const result = await loadingCacheValue.promise; + + strictEqual(events.length, 2); + + strictEqual(events[0].for, "cache"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/set`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { cacheValue }); + + strictEqual(events[1].for, "loading"); + assertInstanceOf(events[1].event, CustomEvent); + strictEqual(events[1].event.type, `${cacheKey}/end`); + strictEqual(events[1].event.cancelable, false); + deepStrictEqual(events[1].event.detail, { loadingCacheValue }); + + deepStrictEqual(loading.store, {}); + deepStrictEqual(cache.store, { [cacheKey]: cacheValue }); + + strictEqual(result, cacheValue); + }); + + it("Construction, multiple loading, first ends first.", async () => { + const cacheKey = "a"; + const cache = new Cache(); + const loading = new Loading(); + + /** @type {Array<{ for: "cache" | "loading", event: Event }>} */ + let events = []; + + cache.addEventListener(`${cacheKey}/set`, (event) => { + events.push({ for: "cache", event }); + }); + + /** @type {EventListener} */ + const loadingListener = (event) => { + events.push({ for: "loading", event }); + }; + + loading.addEventListener(`${cacheKey}/start`, loadingListener); + loading.addEventListener(`${cacheKey}/end`, loadingListener); + + const { promise: firstLoadingResult, resolve: firstLoadingResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + const firstAbortController = new AbortController(); + const firstLoadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + firstLoadingResult, + firstAbortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { + loadingCacheValue: firstLoadingCacheValue, + }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([firstLoadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(firstLoadingCacheValue.timeStamp, "number"); + strictEqual( + performance.now() - firstLoadingCacheValue.timeStamp < 50, + true, + ); + strictEqual(firstLoadingCacheValue.abortController, firstAbortController); + assertInstanceOf(firstLoadingCacheValue.promise, Promise); + + events = []; + + const { + promise: secondLoadingResult, + resolve: secondLoadingResultResolve, + } = + /** @type {Deferred>} */ + (new Deferred()); + const secondAbortController = new AbortController(); + const secondLoadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + secondLoadingResult, + secondAbortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { + loadingCacheValue: secondLoadingCacheValue, + }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([firstLoadingCacheValue, secondLoadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(secondLoadingCacheValue.timeStamp, "number"); + strictEqual( + performance.now() - secondLoadingCacheValue.timeStamp < 50, + true, + ); + strictEqual( + secondLoadingCacheValue.timeStamp >= firstLoadingCacheValue.timeStamp, + true, + ); + strictEqual(secondLoadingCacheValue.abortController, secondAbortController); + assertInstanceOf(secondLoadingCacheValue.promise, Promise); + + events = []; + + const firstCacheValue = Object.freeze({}); + + firstLoadingResultResolve(firstCacheValue); + + const firstResult = await firstLoadingCacheValue.promise; + + strictEqual(events.length, 2); + + strictEqual(events[0].for, "cache"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/set`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { cacheValue: firstCacheValue }); + + strictEqual(events[1].for, "loading"); + assertInstanceOf(events[1].event, CustomEvent); + strictEqual(events[1].event.type, `${cacheKey}/end`); + strictEqual(events[1].event.cancelable, false); + deepStrictEqual(events[1].event.detail, { + loadingCacheValue: firstLoadingCacheValue, + }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([secondLoadingCacheValue]), + }); + deepStrictEqual(cache.store, { [cacheKey]: firstCacheValue }); + + strictEqual(firstResult, firstCacheValue); + + events = []; + + const secondCacheValue = Object.freeze({}); + + secondLoadingResultResolve(secondCacheValue); + + const secondResult = await secondLoadingCacheValue.promise; + + strictEqual(events.length, 2); + + strictEqual(events[0].for, "cache"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/set`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { cacheValue: secondCacheValue }); + + strictEqual(events[1].for, "loading"); + assertInstanceOf(events[1].event, CustomEvent); + strictEqual(events[1].event.type, `${cacheKey}/end`); + strictEqual(events[1].event.cancelable, false); + deepStrictEqual(events[1].event.detail, { + loadingCacheValue: secondLoadingCacheValue, + }); + + deepStrictEqual(loading.store, {}); + deepStrictEqual(cache.store, { [cacheKey]: secondCacheValue }); + + strictEqual(secondResult, secondCacheValue); + }); + + it("Construction, multiple loading, first ends last.", async () => { + const cacheKey = "a"; + const cache = new Cache(); + const loading = new Loading(); + + /** @type {Array<{ for: "cache" | "loading", event: Event }>} */ + let events = []; + + cache.addEventListener(`${cacheKey}/set`, (event) => { + events.push({ for: "cache", event }); + }); + + /** @type {EventListener} */ + const loadingListener = (event) => { + events.push({ for: "loading", event }); + }; + + loading.addEventListener(`${cacheKey}/start`, loadingListener); + loading.addEventListener(`${cacheKey}/end`, loadingListener); + + const { promise: firstLoadingResult, resolve: firstLoadingResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + const firstAbortController = new AbortController(); + const firstLoadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + firstLoadingResult, + firstAbortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { + loadingCacheValue: firstLoadingCacheValue, + }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([firstLoadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(firstLoadingCacheValue.timeStamp, "number"); + strictEqual( + performance.now() - firstLoadingCacheValue.timeStamp < 50, + true, + ); + strictEqual(firstLoadingCacheValue.abortController, firstAbortController); + assertInstanceOf(firstLoadingCacheValue.promise, Promise); + + events = []; + + const { + promise: secondLoadingResult, + resolve: secondLoadingResultResolve, + } = + /** @type {Deferred>} */ + (new Deferred()); + const secondAbortController = new AbortController(); + const secondLoadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + secondLoadingResult, + secondAbortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { + loadingCacheValue: secondLoadingCacheValue, + }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([firstLoadingCacheValue, secondLoadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(secondLoadingCacheValue.timeStamp, "number"); + strictEqual( + performance.now() - secondLoadingCacheValue.timeStamp < 50, + true, + ); + strictEqual( + secondLoadingCacheValue.timeStamp >= firstLoadingCacheValue.timeStamp, + true, + ); + strictEqual(secondLoadingCacheValue.abortController, secondAbortController); + assertInstanceOf(secondLoadingCacheValue.promise, Promise); + + events = []; + + const firstCacheValue = Object.freeze({}); + const secondCacheValue = Object.freeze({}); + + /** @type {Array} */ + const loadingResolveOrder = []; + + const firstLoadingCheck = firstLoadingCacheValue.promise.then( + (firstResult) => { + loadingResolveOrder.push(1); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([secondLoadingCacheValue]), + }); + deepStrictEqual(cache.store, { [cacheKey]: firstCacheValue }); + strictEqual(firstResult, firstCacheValue); + }, + ); + + const secondLoadingCheck = secondLoadingCacheValue.promise.then( + (secondResult) => { + loadingResolveOrder.push(2); + + deepStrictEqual(loading.store, {}); + deepStrictEqual(cache.store, { [cacheKey]: secondCacheValue }); + strictEqual(secondResult, secondCacheValue); + }, + ); + + secondLoadingResultResolve(secondCacheValue); + firstLoadingResultResolve(firstCacheValue); + + await Promise.all([firstLoadingCheck, secondLoadingCheck]); + + deepStrictEqual(loadingResolveOrder, [1, 2]); + + strictEqual(events.length, 4); + + strictEqual(events[0].for, "cache"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/set`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { cacheValue: firstCacheValue }); + + strictEqual(events[1].for, "loading"); + assertInstanceOf(events[1].event, CustomEvent); + strictEqual(events[1].event.type, `${cacheKey}/end`); + strictEqual(events[1].event.cancelable, false); + deepStrictEqual(events[1].event.detail, { + loadingCacheValue: firstLoadingCacheValue, + }); + + strictEqual(events[2].for, "cache"); + assertInstanceOf(events[2].event, CustomEvent); + strictEqual(events[2].event.type, `${cacheKey}/set`); + strictEqual(events[2].event.cancelable, false); + deepStrictEqual(events[2].event.detail, { cacheValue: secondCacheValue }); + + strictEqual(events[3].for, "loading"); + assertInstanceOf(events[3].event, CustomEvent); + strictEqual(events[3].event.type, `${cacheKey}/end`); + strictEqual(events[3].event.cancelable, false); + deepStrictEqual(events[3].event.detail, { + loadingCacheValue: secondLoadingCacheValue, + }); + }); + + it("Construction, aborting loading.", async () => { + const cacheKey = "a"; + const cache = new Cache(); + const loading = new Loading(); + + /** @type {Array<{ for: "cache" | "loading", event: Event }>} */ + let events = []; + + cache.addEventListener(`${cacheKey}/set`, (event) => { + events.push({ for: "cache", event }); + }); + + /** @type {EventListener} */ + const loadingListener = (event) => { + events.push({ for: "loading", event }); + }; + + loading.addEventListener(`${cacheKey}/start`, loadingListener); + loading.addEventListener(`${cacheKey}/end`, loadingListener); + + const cacheValue = "Aborted."; + const abortController = new AbortController(); + const loadingResult = new Promise((resolve) => { + abortController.signal.addEventListener( + "abort", + () => { + resolve(cacheValue); + }, + { once: true }, + ); + }); + + const loadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + loadingResult, + abortController, + ); + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/start`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { loadingCacheValue }); + + deepStrictEqual(loading.store, { + [cacheKey]: new Set([loadingCacheValue]), + }); + deepStrictEqual(cache.store, {}); + + assertTypeOf(loadingCacheValue.timeStamp, "number"); + strictEqual(performance.now() - loadingCacheValue.timeStamp < 50, true); + strictEqual(loadingCacheValue.abortController, abortController); + assertInstanceOf(loadingCacheValue.promise, Promise); + + events = []; + + abortController.abort(); + + const result = await loadingCacheValue.promise; + + strictEqual(events.length, 1); + + strictEqual(events[0].for, "loading"); + assertInstanceOf(events[0].event, CustomEvent); + strictEqual(events[0].event.type, `${cacheKey}/end`); + strictEqual(events[0].event.cancelable, false); + deepStrictEqual(events[0].event.detail, { loadingCacheValue }); + + deepStrictEqual(loading.store, {}); + deepStrictEqual(cache.store, {}); + + strictEqual(result, cacheValue); + }); +}); diff --git a/LoadingContext.mjs b/LoadingContext.mjs new file mode 100644 index 0000000..d86eeef --- /dev/null +++ b/LoadingContext.mjs @@ -0,0 +1,18 @@ +// @ts-check + +/** @import Loading from "./Loading.mjs" */ + +import React from "react"; + +/** + * [React context](https://reactjs.org/docs/context.html) for a + * {@linkcode Loading} instance. + * @type {React.Context} + */ +const LoadingContext = React.createContext( + /** @type {Loading | undefined} */ (undefined), +); + +LoadingContext.displayName = "LoadingContext"; + +export default LoadingContext; diff --git a/LoadingContext.test.mjs b/LoadingContext.test.mjs new file mode 100644 index 0000000..bb33aa7 --- /dev/null +++ b/LoadingContext.test.mjs @@ -0,0 +1,41 @@ +// @ts-check + +import { strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; + +import Loading from "./Loading.mjs"; +import LoadingContext from "./LoadingContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; + +describe("React context `LoadingContext`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./LoadingContext.mjs", import.meta.url), + 120, + ); + }); + + it("Used as a React context.", () => { + let contextValue; + + function TestComponent() { + contextValue = React.useContext(LoadingContext); + return null; + } + + const value = new Loading(); + + createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { value }, + React.createElement(TestComponent), + ), + ); + + strictEqual(contextValue, value); + }); +}); diff --git a/Provider.mjs b/Provider.mjs new file mode 100644 index 0000000..e802d6c --- /dev/null +++ b/Provider.mjs @@ -0,0 +1,71 @@ +// @ts-check + +import React from "react"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import HydrationTimeStampContext from "./HydrationTimeStampContext.mjs"; +import Loading from "./Loading.mjs"; +import LoadingContext from "./LoadingContext.mjs"; + +/** + * React component to provide all the React context required to fully enable + * [`graphql-react`](https://npm.im/graphql-react) functionality: + * + * - {@linkcode HydrationTimeStampContext} + * - {@linkcode CacheContext} + * - {@linkcode LoadingContext} + * @param {ProviderProps} props React component props. + * @example + * Provide a {@linkcode Cache} instance for an app: + * + * ```js + * import Cache from "graphql-react/Cache.mjs"; + * import Provider from "graphql-react/Provider.mjs"; + * import React from "react"; + * + * const cache = new Cache(); + * + * function App({ children }) { + * return React.createElement(Provider, { cache }, children); + * } + * ``` + */ +export default function Provider({ cache, children }) { + const hydrationTimeStampRef = React.useRef( + /** @type {DOMHighResTimeStamp | undefined} */ (undefined), + ); + + if (!hydrationTimeStampRef.current) + hydrationTimeStampRef.current = performance.now(); + + const loadingRef = React.useRef( + /** @type {Loading | undefined} */ (undefined), + ); + + if (!loadingRef.current) loadingRef.current = new Loading(); + + if (!(cache instanceof Cache)) + throw new TypeError("Prop `cache` must be a `Cache` instance."); + + return React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStampRef.current }, + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement( + LoadingContext.Provider, + { value: loadingRef.current }, + children, + ), + ), + ); +} + +/** + * {@linkcode Provider} React component props. + * @typedef {object} ProviderProps + * @prop {Cache} cache {@linkcode Cache} instance. + * @prop {React.ReactNode} [children] React children. + */ diff --git a/Provider.test.mjs b/Provider.test.mjs new file mode 100644 index 0000000..4ad4b1b --- /dev/null +++ b/Provider.test.mjs @@ -0,0 +1,106 @@ +// @ts-check + +import { strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import HydrationTimeStampContext from "./HydrationTimeStampContext.mjs"; +import Loading from "./Loading.mjs"; +import LoadingContext from "./LoadingContext.mjs"; +import Provider from "./Provider.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; +import assertTypeOf from "./test/assertTypeOf.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import suppressReactRenderErrorConsoleOutput from "./test/suppressReactRenderErrorConsoleOutput.mjs"; + +describe("React component `Provider`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./Provider.mjs", import.meta.url), 500); + }); + + it("Prop `cache` missing.", () => { + const revertConsole = suppressReactRenderErrorConsoleOutput(); + + try { + throws(() => { + createReactTestRenderer( + React.createElement( + Provider, + // @ts-expect-error Testing invalid. + {}, + ), + ); + }, new TypeError("Prop `cache` must be a `Cache` instance.")); + } finally { + revertConsole(); + } + }); + + it("Used correctly.", () => { + /** + * @type {Array<{ + * hydrationTimeStampContextValue: number | undefined, + * cacheContextValue: Cache | undefined, + * loadingContextValue: Loading | undefined + * }>} + */ + const results = []; + + function TestComponent() { + results.push({ + hydrationTimeStampContextValue: React.useContext( + HydrationTimeStampContext, + ), + cacheContextValue: React.useContext(CacheContext), + loadingContextValue: React.useContext(LoadingContext), + }); + return null; + } + + const cache = new Cache(); + const testRenderer = createReactTestRenderer( + React.createElement( + Provider, + { cache }, + React.createElement(TestComponent), + ), + ); + + strictEqual(results.length, 1); + assertTypeOf(results[0].hydrationTimeStampContextValue, "number"); + strictEqual( + performance.now() - results[0].hydrationTimeStampContextValue < 100, + true, + ); + strictEqual(results[0].cacheContextValue, cache); + assertInstanceOf(results[0].loadingContextValue, Loading); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + Provider, + { + // @ts-ignore Force the component to re-render by setting a new + // arbitrary prop. + a: true, + cache, + }, + React.createElement(TestComponent), + ), + ); + }); + + strictEqual(results.length, 2); + strictEqual( + results[1].hydrationTimeStampContextValue, + results[0].hydrationTimeStampContextValue, + ); + strictEqual(results[1].cacheContextValue, results[0].cacheContextValue); + strictEqual(results[1].loadingContextValue, results[0].loadingContextValue); + }); +}); diff --git a/cacheDelete.mjs b/cacheDelete.mjs new file mode 100644 index 0000000..2bacb2a --- /dev/null +++ b/cacheDelete.mjs @@ -0,0 +1,29 @@ +// @ts-check + +/** + * @import { CacheEventMap, CacheKey } from "./Cache.mjs" + * @import { CacheKeyMatcher } from "./types.mjs" + */ + +import Cache from "./Cache.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; + +/** + * Deletes {@link Cache.store cache store} entries, dispatching the + * {@linkcode Cache} event {@link CacheEventMap.delete `delete`}. Useful after a + * user logs out. + * @param {Cache} cache Cache to update. + * @param {CacheKeyMatcher} [cacheKeyMatcher] Matches + * {@link CacheKey cache keys} to delete. By default all are matched. + */ +export default function cacheDelete(cache, cacheKeyMatcher) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (cacheKeyMatcher !== undefined && typeof cacheKeyMatcher !== "function") + throw new TypeError("Argument 2 `cacheKeyMatcher` must be a function."); + + for (const cacheKey in cache.store) + if (!cacheKeyMatcher || cacheKeyMatcher(cacheKey)) + cacheEntryDelete(cache, cacheKey); +} diff --git a/cacheDelete.test.mjs b/cacheDelete.test.mjs new file mode 100644 index 0000000..c9a13f5 --- /dev/null +++ b/cacheDelete.test.mjs @@ -0,0 +1,95 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheDelete from "./cacheDelete.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheDelete`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./cacheDelete.mjs", import.meta.url), 400); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheDelete( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKeyMatcher` not a function.", () => { + throws(() => { + cacheDelete( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKeyMatcher` must be a function.")); + }); + + it("Argument 2 `cacheKeyMatcher` unused.", () => { + const cache = new Cache({ a: 1, b: 2 }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/delete", listener); + cache.addEventListener("b/delete", listener); + + cacheDelete(cache); + + strictEqual(events.length, 2); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/delete"); + strictEqual(events[0].cancelable, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "b/delete"); + strictEqual(events[1].cancelable, false); + + deepStrictEqual(cache.store, {}); + }); + + it("Argument 2 `cacheKeyMatcher` used.", () => { + const cache = new Cache({ a: 1, b: 2, c: 3 }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/delete", listener); + cache.addEventListener("b/delete", listener); + cache.addEventListener("c/delete", listener); + + cacheDelete(cache, (cacheKey) => cacheKey !== "b"); + + strictEqual(events.length, 2); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/delete"); + strictEqual(events[0].cancelable, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "c/delete"); + strictEqual(events[1].cancelable, false); + + deepStrictEqual(cache.store, { b: 2 }); + }); +}); diff --git a/cacheEntryDelete.mjs b/cacheEntryDelete.mjs new file mode 100644 index 0000000..1559fdb --- /dev/null +++ b/cacheEntryDelete.mjs @@ -0,0 +1,25 @@ +// @ts-check + +/** @import { CacheEventMap, CacheKey } from "./Cache.mjs" */ + +import Cache from "./Cache.mjs"; + +/** + * Deletes a {@link Cache.store cache store} entry, dispatching the + * {@linkcode Cache} event {@link CacheEventMap.delete `delete`}. + * @param {Cache} cache Cache to update. + * @param {CacheKey} cacheKey Cache key. + */ +export default function cacheEntryDelete(cache, cacheKey) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (typeof cacheKey !== "string") + throw new TypeError("Argument 2 `cacheKey` must be a string."); + + if (cacheKey in cache.store) { + delete cache.store[cacheKey]; + + cache.dispatchEvent(new CustomEvent(`${cacheKey}/delete`)); + } +} diff --git a/cacheEntryDelete.test.mjs b/cacheEntryDelete.test.mjs new file mode 100644 index 0000000..da1b04f --- /dev/null +++ b/cacheEntryDelete.test.mjs @@ -0,0 +1,78 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheEntryDelete`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./cacheEntryDelete.mjs", import.meta.url), + 350, + ); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheEntryDelete( + // @ts-expect-error Testing invalid. + true, + "a", + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKey` not a string.", () => { + throws(() => { + cacheEntryDelete( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKey` must be a string.")); + }); + + it("Entry not populated.", () => { + const cache = new Cache({ a: 1 }); + + /** @type {Array} */ + const events = []; + + cache.addEventListener("b/delete", (event) => { + events.push(event); + }); + + cacheEntryDelete(cache, "b"); + + deepStrictEqual(events, []); + deepStrictEqual(cache.store, { a: 1 }); + }); + + it("Entry populated.", () => { + const deleteCacheKey = "b"; + const cache = new Cache({ a: 1, [deleteCacheKey]: 2 }); + + /** @type {Array} */ + const events = []; + + cache.addEventListener(`${deleteCacheKey}/delete`, (event) => { + events.push(event); + }); + + cacheEntryDelete(cache, deleteCacheKey); + + strictEqual(events.length, 1); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, `${deleteCacheKey}/delete`); + strictEqual(events[0].cancelable, false); + + deepStrictEqual(cache.store, { a: 1 }); + }); +}); diff --git a/cacheEntryPrune.mjs b/cacheEntryPrune.mjs new file mode 100644 index 0000000..7fdbc0f --- /dev/null +++ b/cacheEntryPrune.mjs @@ -0,0 +1,30 @@ +// @ts-check + +/** @import { CacheEventMap, CacheKey } from "./Cache.mjs" */ + +import Cache from "./Cache.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; + +/** + * Prunes a {@link Cache.store cache store} entry (if present) by dispatching + * the {@linkcode Cache} event {@link CacheEventMap.prune `prune`} and if no + * listener cancels it via `event.preventDefault()`, using + * {@linkcode cacheEntryDelete}. + * @param {Cache} cache Cache to update. + * @param {CacheKey} cacheKey Cache key. + */ +export default function cacheEntryPrune(cache, cacheKey) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (typeof cacheKey !== "string") + throw new TypeError("Argument 2 `cacheKey` must be a string."); + + if (cacheKey in cache.store) { + const notCanceled = cache.dispatchEvent( + new CustomEvent(`${cacheKey}/prune`, { cancelable: true }), + ); + + if (notCanceled) cacheEntryDelete(cache, cacheKey); + } +} diff --git a/cacheEntryPrune.test.mjs b/cacheEntryPrune.test.mjs new file mode 100644 index 0000000..4553ba4 --- /dev/null +++ b/cacheEntryPrune.test.mjs @@ -0,0 +1,120 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheEntryPrune from "./cacheEntryPrune.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheEntryPrune`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./cacheEntryPrune.mjs", import.meta.url), + 350, + ); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheEntryPrune( + // @ts-expect-error Testing invalid. + true, + "a", + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKey` not a string.", () => { + throws(() => { + cacheEntryPrune( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKey` must be a string.")); + }); + + it("Entry not populated.", () => { + const initialCacheStore = { a: 1 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("b/prune", listener); + cache.addEventListener("b/delete", listener); + + cacheEntryPrune(cache, "b"); + + deepStrictEqual(events, []); + deepStrictEqual(cache.store, initialCacheStore); + }); + + it("Entry populated, prune event not canceled.", () => { + const pruneCacheKey = "b"; + const cache = new Cache({ a: 1, [pruneCacheKey]: 2 }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener(`${pruneCacheKey}/prune`, listener); + cache.addEventListener(`${pruneCacheKey}/delete`, listener); + + cacheEntryPrune(cache, pruneCacheKey); + + strictEqual(events.length, 2); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, `${pruneCacheKey}/prune`); + strictEqual(events[0].cancelable, true); + strictEqual(events[0].defaultPrevented, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, `${pruneCacheKey}/delete`); + strictEqual(events[1].cancelable, false); + + deepStrictEqual(cache.store, { a: 1 }); + }); + + it("Entry populated, prune event canceled.", () => { + const pruneCacheKey = "b"; + const initialCacheStore = { a: 1, [pruneCacheKey]: 2 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + cache.addEventListener(`${pruneCacheKey}/prune`, (event) => { + event.preventDefault(); + events.push(event); + }); + cache.addEventListener(`${pruneCacheKey}/delete`, (event) => { + events.push(event); + }); + + cacheEntryPrune(cache, pruneCacheKey); + + strictEqual(events.length, 1); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, `${pruneCacheKey}/prune`); + strictEqual(events[0].cancelable, true); + strictEqual(events[0].defaultPrevented, true); + + deepStrictEqual(cache.store, initialCacheStore); + }); +}); diff --git a/cacheEntrySet.mjs b/cacheEntrySet.mjs new file mode 100644 index 0000000..8084d9f --- /dev/null +++ b/cacheEntrySet.mjs @@ -0,0 +1,30 @@ +// @ts-check + +/** @import { CacheEventMap, CacheKey, CacheValue } from "./Cache.mjs" */ + +import Cache from "./Cache.mjs"; + +/** + * Sets a {@link Cache.store cache store} entry, dispatching the + * {@linkcode Cache} event {@link CacheEventMap.set `set`}. + * @param {Cache} cache Cache to update. + * @param {CacheKey} cacheKey Cache key. + * @param {CacheValue} cacheValue Cache value. + */ +export default function cacheEntrySet(cache, cacheKey, cacheValue) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (typeof cacheKey !== "string") + throw new TypeError("Argument 2 `cacheKey` must be a string."); + + cache.store[cacheKey] = cacheValue; + + cache.dispatchEvent( + new CustomEvent(`${cacheKey}/set`, { + detail: { + cacheValue, + }, + }), + ); +} diff --git a/cacheEntrySet.test.mjs b/cacheEntrySet.test.mjs new file mode 100644 index 0000000..ca0fec2 --- /dev/null +++ b/cacheEntrySet.test.mjs @@ -0,0 +1,72 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheEntrySet from "./cacheEntrySet.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheEntrySet`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./cacheEntrySet.mjs", import.meta.url), + 350, + ); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheEntrySet( + // @ts-expect-error Testing invalid. + true, + "a", + {}, + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKey` not a string.", () => { + throws(() => { + cacheEntrySet( + new Cache(), + // @ts-expect-error Testing invalid. + true, + {}, + ); + }, new TypeError("Argument 2 `cacheKey` must be a string.")); + }); + + it("Sets a cache entry.", () => { + const initialCacheStore = { a: 1 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + const setCacheKey = "b"; + const setCacheValue = 2; + const setEventName = `${setCacheKey}/set`; + + cache.addEventListener(setEventName, (event) => { + events.push(event); + }); + + cacheEntrySet(cache, setCacheKey, setCacheValue); + + strictEqual(events.length, 1); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, setEventName); + strictEqual(events[0].cancelable, false); + deepStrictEqual(events[0].detail, { cacheValue: setCacheValue }); + + deepStrictEqual(cache.store, { + ...initialCacheStore, + [setCacheKey]: setCacheValue, + }); + }); +}); diff --git a/cacheEntryStale.mjs b/cacheEntryStale.mjs new file mode 100644 index 0000000..729de12 --- /dev/null +++ b/cacheEntryStale.mjs @@ -0,0 +1,25 @@ +// @ts-check + +/** @import { CacheEventMap, CacheKey } from "./Cache.mjs" */ + +import Cache from "./Cache.mjs"; + +/** + * Stales a {@link Cache.store cache store} entry (throwing an error if missing) + * by dispatching the {@linkcode Cache} event + * {@link CacheEventMap.stale `stale`} to signal it should probably be reloaded. + * @param {Cache} cache Cache to update. + * @param {CacheKey} cacheKey Cache key. + */ +export default function cacheEntryStale(cache, cacheKey) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (typeof cacheKey !== "string") + throw new TypeError("Argument 2 `cacheKey` must be a string."); + + if (!(cacheKey in cache.store)) + throw new Error(`Cache key \`${cacheKey}\` isn’t in the store.`); + + cache.dispatchEvent(new CustomEvent(`${cacheKey}/stale`)); +} diff --git a/cacheEntryStale.test.mjs b/cacheEntryStale.test.mjs new file mode 100644 index 0000000..87963f3 --- /dev/null +++ b/cacheEntryStale.test.mjs @@ -0,0 +1,76 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheEntryStale from "./cacheEntryStale.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheEntryStale`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./cacheEntryStale.mjs", import.meta.url), + 350, + ); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheEntryStale( + // @ts-expect-error Testing invalid. + true, + "a", + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKey` not a string.", () => { + throws(() => { + cacheEntryStale( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKey` must be a string.")); + }); + + it("Entry not populated.", () => { + const cacheKey = "a"; + + throws( + () => { + cacheEntryStale(new Cache(), cacheKey); + }, + new Error(`Cache key \`${cacheKey}\` isn’t in the store.`), + ); + }); + + it("Entry populated.", () => { + const cacheKey = "a"; + const initialCacheStore = { [cacheKey]: 1 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + const staleEventName = `${cacheKey}/stale`; + + cache.addEventListener(staleEventName, (event) => { + events.push(event); + }); + + cacheEntryStale(cache, cacheKey); + + strictEqual(events.length, 1); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, staleEventName); + strictEqual(events[0].cancelable, false); + + deepStrictEqual(cache.store, initialCacheStore); + }); +}); diff --git a/cachePrune.mjs b/cachePrune.mjs new file mode 100644 index 0000000..c2000d2 --- /dev/null +++ b/cachePrune.mjs @@ -0,0 +1,28 @@ +// @ts-check + +/** + * @import { CacheKey } from "./Cache.mjs" + * @import { CacheKeyMatcher } from "./types.mjs" + */ + +import Cache from "./Cache.mjs"; +import cacheEntryPrune from "./cacheEntryPrune.mjs"; + +/** + * Prunes {@link Cache.store cache store} entries by using + * {@linkcode cacheEntryPrune} for each entry. Useful after a mutation. + * @param {Cache} cache Cache to update. + * @param {CacheKeyMatcher} [cacheKeyMatcher] Matches + * {@link CacheKey cache keys} to prune. By default all are matched. + */ +export default function cachePrune(cache, cacheKeyMatcher) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (cacheKeyMatcher !== undefined && typeof cacheKeyMatcher !== "function") + throw new TypeError("Argument 2 `cacheKeyMatcher` must be a function."); + + for (const cacheKey in cache.store) + if (!cacheKeyMatcher || cacheKeyMatcher(cacheKey)) + cacheEntryPrune(cache, cacheKey); +} diff --git a/cachePrune.test.mjs b/cachePrune.test.mjs new file mode 100644 index 0000000..8769ffb --- /dev/null +++ b/cachePrune.test.mjs @@ -0,0 +1,123 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cachePrune from "./cachePrune.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cachePrune`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./cachePrune.mjs", import.meta.url), 400); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cachePrune( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKeyMatcher` not a function.", () => { + throws(() => { + cachePrune( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKeyMatcher` must be a function.")); + }); + + it("Argument 2 `cacheKeyMatcher` unused.", () => { + const cache = new Cache({ a: 1, b: 2 }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/prune", listener); + cache.addEventListener("a/delete", listener); + + cache.addEventListener("b/prune", listener); + cache.addEventListener("b/delete", listener); + + cachePrune(cache); + + strictEqual(events.length, 4); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/prune"); + strictEqual(events[0].cancelable, true); + strictEqual(events[0].defaultPrevented, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "a/delete"); + strictEqual(events[1].cancelable, false); + + assertInstanceOf(events[2], CustomEvent); + strictEqual(events[2].type, "b/prune"); + strictEqual(events[2].cancelable, true); + strictEqual(events[2].defaultPrevented, false); + + assertInstanceOf(events[3], CustomEvent); + strictEqual(events[3].type, "b/delete"); + strictEqual(events[3].cancelable, false); + + deepStrictEqual(cache.store, {}); + }); + + it("Argument 2 `cacheKeyMatcher` used.", () => { + const cache = new Cache({ a: 1, b: 2, c: 3 }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/prune", listener); + cache.addEventListener("a/delete", listener); + + cache.addEventListener("b/prune", listener); + cache.addEventListener("b/delete", listener); + + cache.addEventListener("c/prune", listener); + cache.addEventListener("c/delete", listener); + + cachePrune(cache, (cacheKey) => cacheKey !== "b"); + + strictEqual(events.length, 4); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/prune"); + strictEqual(events[0].cancelable, true); + strictEqual(events[0].defaultPrevented, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "a/delete"); + strictEqual(events[1].cancelable, false); + + assertInstanceOf(events[2], CustomEvent); + strictEqual(events[2].type, "c/prune"); + strictEqual(events[2].cancelable, true); + strictEqual(events[2].defaultPrevented, false); + + assertInstanceOf(events[3], CustomEvent); + strictEqual(events[3].type, "c/delete"); + strictEqual(events[3].cancelable, false); + + deepStrictEqual(cache.store, { b: 2 }); + }); +}); diff --git a/cacheStale.mjs b/cacheStale.mjs new file mode 100644 index 0000000..be7b500 --- /dev/null +++ b/cacheStale.mjs @@ -0,0 +1,28 @@ +// @ts-check + +/** + * @import { CacheKey } from "./Cache.mjs" + * @import { CacheKeyMatcher } from "./types.mjs" + */ + +import Cache from "./Cache.mjs"; +import cacheEntryStale from "./cacheEntryStale.mjs"; + +/** + * Stales {@link Cache.store cache store} entries by using + * {@linkcode cacheEntryStale} for each entry. Useful after a mutation. + * @param {Cache} cache Cache to update. + * @param {CacheKeyMatcher} [cacheKeyMatcher] Matches + * {@link CacheKey cache keys} to stale. By default all are matched. + */ +export default function cacheStale(cache, cacheKeyMatcher) { + if (!(cache instanceof Cache)) + throw new TypeError("Argument 1 `cache` must be a `Cache` instance."); + + if (cacheKeyMatcher !== undefined && typeof cacheKeyMatcher !== "function") + throw new TypeError("Argument 2 `cacheKeyMatcher` must be a function."); + + for (const cacheKey in cache.store) + if (!cacheKeyMatcher || cacheKeyMatcher(cacheKey)) + cacheEntryStale(cache, cacheKey); +} diff --git a/cacheStale.test.mjs b/cacheStale.test.mjs new file mode 100644 index 0000000..6584552 --- /dev/null +++ b/cacheStale.test.mjs @@ -0,0 +1,97 @@ +// @ts-check + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import Cache from "./Cache.mjs"; +import cacheStale from "./cacheStale.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `cacheStale`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./cacheStale.mjs", import.meta.url), 450); + }); + + it("Argument 1 `cache` not a `Cache` instance.", () => { + throws(() => { + cacheStale( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cache` must be a `Cache` instance.")); + }); + + it("Argument 2 `cacheKeyMatcher` not a function.", () => { + throws(() => { + cacheStale( + new Cache(), + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `cacheKeyMatcher` must be a function.")); + }); + + it("Argument 2 `cacheKeyMatcher` unused.", () => { + const initialCacheStore = { a: 1, b: 2 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/stale", listener); + cache.addEventListener("b/stale", listener); + + cacheStale(cache); + + strictEqual(events.length, 2); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/stale"); + strictEqual(events[0].cancelable, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "b/stale"); + strictEqual(events[1].cancelable, false); + + deepStrictEqual(cache.store, initialCacheStore); + }); + + it("Argument 2 `cacheKeyMatcher` used.", () => { + const initialCacheStore = { a: 1, b: 2, c: 3 }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const events = []; + + /** @type {EventListener} */ + const listener = (event) => { + events.push(event); + }; + + cache.addEventListener("a/stale", listener); + cache.addEventListener("b/stale", listener); + cache.addEventListener("c/stale", listener); + + cacheStale(cache, (cacheKey) => cacheKey !== "b"); + + strictEqual(events.length, 2); + + assertInstanceOf(events[0], CustomEvent); + strictEqual(events[0].type, "a/stale"); + strictEqual(events[0].cancelable, false); + + assertInstanceOf(events[1], CustomEvent); + strictEqual(events[1].type, "c/stale"); + strictEqual(events[1].cancelable, false); + + deepStrictEqual(cache.store, initialCacheStore); + }); +}); diff --git a/changelog.md b/changelog.md index 4856dc9..e2ddd2e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,445 @@ # graphql-react changelog +## Next + +### Major + +- Updated Node.js support to `^18.18.0 || ^20.9.0 || >=22.0.0`. +- Use the Node.js test runner API and remove the dev dependency [`test-director`](https://npm.im/test-director). +- Refactored tests to use the standard `AbortController`, `AbortSignal`, `Event`, `EventTarget`, `File`, `FormData`, and `Response` APIs available in modern Node.js and removed the dev dependencies [`abort-controller`](https://npm.im/abort-controller), [`event-target-shim`](https://npm.im/event-target-shim), and [`node-fetch`](https://npm.im/node-fetch). +- Use the TypeScript v5.5+ JSDoc tag `@import` to import types in modules. + +### Patch + +- Updated dev dependencies. +- Updated the `package.json` field `repository` to conform to new npm requirements. +- Updated GitHub Actions CI config: + - No longer run the workflow on pull request. + - Enable manual workflow dispatching. + - Run tests with Node.js v18, v20, v22. + - Updated `actions/checkout` to v4. + - Updated `actions/setup-node` to v4. + +## 20.0.0 + +### Major + +- Updated the [`react-waterfall-render`](https://npm.im/react-waterfall-render) dependency to v5. + +### Patch + +- Updated the [`extract-files`](https://npm.im/extract-files) dependency to v13. +- Updated dev dependencies. +- Use the `node:` URL scheme for Node.js builtin module imports in tests. +- Updated ESLint config. +- Replaced TypeScript `Record` types with index signatures. +- Added missing readme “Installation” section import map instructions for [`is-plain-obj`](https://npm.im/is-plain-obj). +- Added [Browserslist](https://browsersl.ist) links to the readme. +- Tweaked the readme. +- Fixed broken links in the readme. +- Fixed a broken link in the v13.0.0 changelog entry. + +## 19.0.0 + +### Major + +- Updated Node.js support to `^14.17.0 || ^16.0.0 || >= 18.0.0`. + +### Patch + +- Updated the [`react`](https://npm.im/react) peer dependency to `16.14 - 18`. +- Removed the redundant [`react-dom`](https://npm.im/react-dom) peer dependency. +- Updated dependencies. +- Removed the [`@testing-library/react-hooks`](https://npm.im/@testing-library/react-hooks) dev dependency and rewrote React hook tests using [`react-test-renderer`](https://npm.im/react-test-renderer), a new test utility function `createReactTestRenderer`, and a custom React component `ReactHookTest`. +- Removed the [`fetch-blob`](https://npm.im/fetch-blob) and [`formdata-node`](https://npm.im/formdata-node) dev dependencies. Instead, `File` and `FormData` are imported from [`node-fetch`](https://npm.im/node-fetch). +- Updated `jsconfig.json`: + - Set `compilerOptions.maxNodeModuleJsDepth` to `10`. + - Set `compilerOptions.module` to `nodenext`. +- Updated GitHub Actions CI config: + - Run tests with Node.js v14, v16, v18. +- Removed the now redundant `not IE > 0` from the Browserslist query. +- Updated `react-dom/server` imports in tests to suit React v18. +- Fixed the `fetchGraphQL` test with the global `fetch` API unavailable for new versions of Node.js that have the `fetch` global. +- Use `globalThis` instead of `global` in tests. +- Use `ReactTestRenderer` instead of `ReactDOMServer.renderToStaticMarkup` in some React context related tests. +- Removed some unnecessary JSDoc comments in tests. +- Fixed some JSDoc links. +- Revamped the readme: + - Removed the badges. + - Updated the “Examples” section to reflect the [`graphql-react` examples repo](https://github.com/jaydenseric/graphql-react-examples) migration from [Node.js](https://nodejs.org), [Next.js](https://nextjs.org), and [Vercel](https://vercel.com) to [Deno](https://deno.land), [Ruck](https://ruck.tech), and [Fly.io](https://fly.io). + - Added information about Deno, import maps, TypeScript config, and [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). + +## 18.0.0 + +### Major + +- Renamed the type `FetchGraphQLResultErrors` to `FetchGraphQLResultError` in `fetchGraphQL.mjs`. + +### Minor + +- Added the new type `FetchGraphQLResultErrorLoading` to `fetchGraphQL.mjs` containing the GraphQL result error types related to loading that are generated on the client, not the GraphQL server. + +### Patch + +- Updated dev dependencies. +- Simplified dev dependencies and config for ESLint. +- Updated GitHub Actions CI config. +- Fixed issues with GraphQL result related types from `types.mjs`. +- Improved various JSDoc descriptions. +- Improved the types relating to the `Deferred` class used in tests. + +## 17.0.0 + +### Major + +- Updated the [`extract-files`](https://npm.im/extract-files) dependency. +- Updated the [`react-waterfall-render`](https://npm.im/react-waterfall-render) dependency. +- Implemented TypeScript types via JSDoc comments, fixing [#6](https://github.com/jaydenseric/graphql-react/issues/6). + +### Patch + +- Updated dev dependencies. +- Sorted the contents of the package `files` and `exports` fields. +- Removed the [`jsdoc-md`](https://npm.im/jsdoc-md) dev dependency and the package `docs-update` and `docs-check` scripts, replacing the readme “API” section with a manually written “Exports” section. +- Check TypeScript types via a new package `types` script. +- Replaced the [`formdata-node`](https://npm.im/formdata-node) dev dependency with [`formdata-polyfill`](https://npm.im/formdata-polyfill) and [`fetch-blob`](https://npm.im/fetch-blob). +- Updated GraphQL spec URLs in JSDoc and regular comments. +- Readme tweaks. +- Added a `license.md` MIT License file, fixing [#54](https://github.com/jaydenseric/graphql-react/issues/54). + +## 16.0.0 + +### Major + +- Updated Node.js support to `^12.22.0 || ^14.17.0 || >= 16.0.0`. +- Updated dependencies, some of which require newer Node.js versions than previously supported. +- Public modules are now individually listed in the package `files` and `exports` fields. +- Removed `./package` from the package `exports` field; the full `package.json` filename must be used in a `require` path. +- Removed the package main index module; deep imports must be used. +- Shortened public module deep import paths, removing the `/public/`. +- The API is now ESM in `.mjs` files instead of CJS in `.js` files, [accessible via `import` but not `require`](https://nodejs.org/dist/latest/docs/api/esm.html#require). +- Switched back to using `React.createElement` instead of the [the new React JSX runtime](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html). + +### Patch + +- Also run GitHub Actions CI with Node.js v17. +- Simplified package scripts. +- Removed the [`isobject`](https://npm.im/isobject) dependency. +- Refactored the `useCacheEntryPrunePrevention` React hook to avoid the `React.useCallback` React hook. +- Avoid named imports from [`react`](https://npm.im/react) and [`react-dom`](https://npm.im/react-dom) as they’re not proper Node.js ESM. +- Removed conditionality on the Node.js global `process.env.NODE_ENV`. +- Configured polyfilled globals in ESLint config for [`eslint-plugin-compat`](https://npm.im/eslint-plugin-compat). +- Fixed JSDoc grammar typos. +- Reorganized the test file structure. +- Corrected a test name. +- Test the bundle sizes for public modules individually. +- Use a new `assertBundleSize` function to assert module bundle size in tests: + - Failure message contains details about the bundle size and how much the limit was exceeded. + - Errors when the surplus is greater than 25% of the limit, suggesting the limit should be reduced. + - Resolves the minified bundle and its gzipped size for debugging in tests. +- Configured Prettier option `singleQuote` to the default, `false`. +- Documentation tweaks. + +## 15.0.0 + +### Major + +- Updated the [`extract-files`](https://npm.im/extract-files) dependency to [v11](https://github.com/jaydenseric/extract-files/releases/tag/v11.0.0). This dependency is used by the function `fetchOptionsGraphQL`. + +### Patch + +- Updated dev dependencies. +- Renamed imports in the test index module. +- Amended the changelog entries for v11.0.0, v11.0.2, and v14.0.0. + +## 14.0.0 + +### Major + +- Updated Node.js support to `^12.20 || >= 14.13`. +- Updated dependencies, some of which require newer Node.js versions than were previously supported. +- Replaced the the `package.json` `exports` field public [subpath folder mapping](https://nodejs.org/api/packages.html#packages_subpath_folder_mappings) (deprecated by Node.js) with a [subpath pattern](https://nodejs.org/api/packages.html#packages_subpath_patterns). Deep `require` paths within `graphql-react/public/` must now include the `.js` file extension. +- The tests are now ESM in `.mjs` files instead of CJS in `.js` files. + +### Patch + +- Updated GitHub Actions CI config to run tests with Node.js v12, v14, v16. +- Simplified JSDoc related package scripts now that [`jsdoc-md`](https://npm.im/jsdoc-md) v10+ automatically generates a Prettier formatted readme. +- Added a package `test:jsdoc` script that checks the readme API docs are up to date with the source JSDoc. +- Test the bundle size using [`esbuild`](https://npm.im/esbuild) instead of [`webpack`](https://npm.im/webpack) and [`disposable-directory`](https://npm.im/disposable-directory). +- Increased the documented bundle size to “< 4 kB” to match that of [`esbuild`](https://npm.im/esbuild) instead of [`webpack`](https://npm.im/webpack). +- Use the correct `kB` symbol instead of `KB` wherever bundle size is mentioned in the package description and readme. +- Don’t destructure `require` from [`react`](https://npm.im/react) to slightly improve the [`esbuild`](https://npm.im/esbuild) bundle size. +- Use the `.js` file extension in internal `require` paths. +- Updated the [example Next.js app](https://graphql-react.vercel.app) URL in the readme. +- Readme tweaks. +- The file `changelog.md` is no longer published. + +## 13.0.0 + +### Major + +- Updated Node.js version support to `^12.0.0 || >= 13.7.0`. +- Stopped supporting Internet Explorer. +- Updated the [`react`](https://npm.im/react) and [`react-dom`](https://npm.im/react-dom) peer dependencies to `16.14 - 17`. +- Use [the new JSX runtime](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html). +- Reorganized the file structure and replaced the entire API: + + - Removed all of the previous public exports for the old API: + - `GraphQL` + - `GraphQLContext` + - `GraphQLProvider` + - `hashObject` + - `reportCacheErrors` + - `useGraphQL` + - `ssr` + - Added public exports for the new API, available as named imports from the index and as deep imports from `graphql-react/public/` `.js` CJS modules: + - `Cache` + - `CacheContext` + - `HYDRATION_TIME_MS` + - `HydrationTimeStampContext` + - `Loading` + - `LoadingCacheValue` + - `LoadingContext` + - `Provider` + - `cacheDelete` + - `cacheEntryDelete` + - `cacheEntryPrune` + - `cacheEntrySet` + - `cacheEntryStale` + - `cachePrune` + - `cacheStale` + - `fetchGraphQL` + - `fetchOptionsGraphQL` + - `useAutoAbortLoad` + - `useAutoLoad` + - `useCache` + - `useCacheEntry` + - `useCacheEntryPrunePrevention` + - `useLoadGraphQL` + - `useLoadOnDelete` + - `useLoadOnMount` + - `useLoadOnStale` + - `useLoading` + - `useLoadingEntry` + - `useWaterfallLoad` + - The function [`waterfallRender`](https://github.com/jaydenseric/react-waterfall-render/tree/v1.0.0#function-waterfallrender) from [`react-waterfall-render`](https://npm.im/react-waterfall-render) should now be used for server side rendering, fixing [#57](https://github.com/jaydenseric/graphql-react/issues/57). + - In addition to the previously required globals, consider polyfilling: + - [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) + - [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) + - [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event) + - [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) + - [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Window/performance) + + The API for the cache (centered around a `Cache` instance provided in the `CacheContext` React context) is separated from the API for loading (centered around a `Loading` instance provided in the `LoadingContext` React context). Although the new loading system should work well for everyone, it could be totally avoided in an app that implements a custom alternative. + + Instead of using the old [`mitt`](https://npm.im/mitt) dependency for events, the `Cache` and `Loading` classes extend the native [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) global available in modern browsers and Node.js; a powerful and familiar event system with zero bundle size cost. + + The new API avoids class methods that add to bundle size regardless if they are used, in favor of focused functions that can be imported to process instances as arguments. For example, one route in your app may only render a cache entry, while another may have a form that makes the global cache stale. If the functionality to make the cache stale was a `Cache` instance method, it would increase the bundle size for the entire app, whereas a function imported in the second route will only grow the bundle size for that route. Features can be added to the API over time without growing everyone’s bundles. + + There are now functions that can be imported to directly manipulate the cache. The functions `cacheEntrySet` and `cacheEntryDelete` update a particular entry, and `cacheDelete` deletes all cache. + + There is a new approach for dealing with stale cache. The function `cacheEntryStale` signals a single entry is stale, and `cacheStale` does the same for all entries (useful after a mutation). These functions don’t actually update cache entries; they simply dispatch cache entry stale events and it’s up to components to listen for this event and reload the cache entry in response, typically via the `useLoadOnStale` React hook. + + Cache entries that are not relevant to the current view can now be pruned on demand using the functions `cacheEntryPrune` for a single entry, or `cachePrune` for all entries, fixing [#55](https://github.com/jaydenseric/graphql-react/issues/55). These functions work by dispatching cache entry prune events on the `Cache` instance, and for each event not cancelled by a listener with `event.preventDefault()`, the cache entry is deleted. The `useCacheEntryPrunePrevention` React hook can be used to automatically cancel pruning of a cache entry used in a component. + + Cache keys are now manually defined instead of automatically derived from `fetch` options hashes, fixing [#56](https://github.com/jaydenseric/graphql-react/issues/56). This is easier to understand, is faster to render, and results in a smaller bundle size without the old [`fnv1a`](https://npm.im/fnv1a) dependency for hashing. + + Instead of one `useGraphQL` React hook with complex options that all add to a component’s bundle size regardless if they are used, there are now several more focused React hooks that can be composed to do exactly the work required, fixing [#53](https://github.com/jaydenseric/graphql-react/issues/53). + + The React hooks can be composed with custom ones to load and cache any type of data, not just GraphQL, using any method, not just `fetch`. + + The new loading system provides the ability to abort loading at any time, implemented using the native [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) global available in modern browsers and Node.js, fixing [#24](https://github.com/jaydenseric/graphql-react/issues/24). Many of the new React hooks leverage this for features such as automatically aborting loading a cache entry when the component loading it unmounts. The new API makes it trivially easy to build features as auto-suggest search inputs that abort the last loading on new input, or page queries that abort loading if the user abandons the route. + + While the new API may seem to have an intimidating number of public exports, the average Next.js app that queries and renders data from a GraphQL API will only use a few. For inspiration, see the readme “Examples” section. + +- Published modules now contain JSDoc comments, which might affect TypeScript projects. + +### Patch + +- Updated dependencies. +- Removed Babel and related dependencies and config. +- Updated GitHub Actions CI config: + - Updated `actions/checkout` to v2. + - Updated `actions/setup-node` to v2. + - Don’t specify the `CI` environment variable as it’s set by default. +- Stop using [`hard-rejection`](https://npm.im/hard-rejection) to detect unhandled `Promise` rejections in tests, as Node.js v15+ does this natively. +- Test the bundle size manually using [`webpack`](https://npm.im/webpack) v5, and remove [`size-limit`](https://npm.im/size-limit) related dev dependencies, config, and scripts. +- Tweaked the package description. +- Readme edits, including: + - Updated the Relay and Apollo URLs. + - Mention polyfilling any required globals in the “Setup” section. + - Removed the “Usage” section. + - Tweaked links in the “Support” section. + - Removed the “Apollo comparison” section. + +## 12.0.1 + +### Patch + +- Updated the [`react`](https://npm.im/react) and [`react-dom`](https://npm.im/react-dom) peer dependencies to `16.8 - 17`. +- Updated dependencies. +- Also run GitHub Actions with Node.js v15. + +## 12.0.0 + +### Major + +- Concurrent GraphQL operations with the same cache key no longer share the first request. +- The `GraphQL` instance property `operations` type has changed: + + ```diff + - object> + + object>> + ``` + +### Patch + +- Updated dev dependencies. +- Improved the test utility `promisifyEvent` function. +- Test the the `GraphQL` instance method `operate` option `reloadOnLoad` in isolation. +- Test better the order of the `GraphQL` instance method `operate` triggered events. +- Refactored the `GraphQL` instance method `operate` to eliminate the `GraphQL` private instance method `fetch` and reduce the chance of race conditions in consumer code. +- Reduced the number of promises created by the `GraphQL` instance method `operate` when the `reloadOnLoad` and `reloadOnLoad` options are `false`. +- Added a code example for how to await all loading GraphQL operations. +- Used consistent JSDoc types for promises that resolve `void`. +- Tweaked JSDoc. +- Tweaked changelog entries. + +## 11.2.0 + +### Minor + +- Added a new `cacheKeyCreator` option to the `GraphQL` instance method `operate` and the `useGraphQL` React hook. +- The previously private `hashObject` function is now publicly exported. + +### Patch + +- Replaced Node.js deprecated `notEqual` assertions with `notStrictEqual` in tests. +- Use the `TypeError` class instead of `Error` for relevant errors. + +## 11.1.0 + +### Minor + +- Allow React component `displayName` and `propTypes` to be removed in production builds, fixing [#51](https://github.com/jaydenseric/graphql-react/issues/51). +- Refactored the `useGraphQL` React hook to do less work for following renders if the `operation` and `fetchOptionsOverride` options are defined outside the component or memoized using the [`React.useMemo`](https://reactjs.org/docs/hooks-reference.html#usememo) hook. +- Memoize what the `useGraphQL` React hook returns for more efficient hook composition. +- Added a new `loadedCacheValue` property to the GraphQL operation status object returned by the `useGraphQL` React hook. This allows cache for an earlier operation to be rendered while loading changes to the query, variables, or `fetch` options. + +### Patch + +- Updated dependencies. +- Use [`coverage-node`](https://npm.im/coverage-node) to enforce 100% code coverage for tests. +- Increased the universal API size-limit from 3 KB to 3.5 KB. +- Updated the `useGraphQL` React hook examples to use the [GitHub GraphQL API](https://docs.github.com/en/graphql). +- Improved the `useGraphQL` React hook tests. +- Improved documentation. + +## 11.0.4 + +### Patch + +- Clearly documented ways to `import` and `require` the package exports. + +## 11.0.3 + +### Patch + +- Updated the [`extract-files`](https://npm.im/extract-files) dependency to [v9.0.0](https://github.com/jaydenseric/extract-files/releases/tag/v9.0.0), and used its new deep `require` path. +- Updated dev dependencies. +- No longer test Node.js v13 in GitHub Actions CI. +- Corrected the Browserslist query in the Babel config for the server API. +- Write tests as CJS and no longer separately build and test ESM and CJS to simplify package scripts, Babel and ESLint config. +- Removed the [`@babel/plugin-proposal-class-properties`](https://npm.im/@babel/plugin-proposal-class-properties) dev dependency and config, as [`@babel/preset-env`](https://npm.im/@babel/preset-env) has handed this via it’s `shippedProposals` options [since v7.10.0](https://babeljs.io/blog/2020/05/25/7.10.0#class-properties-and-private-methods-to-shippedproposals-option-of-babel-preset-env-11451-https-githubcom-babel-babel-pull-11451). +- Removed unnecessary `.js` file extensions from `require` paths. +- Improved polyfilling globals in tests: + - Use [`revertable-globals`](https://npm.im/revertable-globals) to define globals per-test. + - Use [`node-fetch`](https://npm.im/node-fetch) v3 instead of [`cross-fetch`](https://npm.im/cross-fetch). +- Removed a no longer necessary [`formdata-node`](https://npm.im/formdata-node) workaround in `graphqlFetchOptions` tests. +- Removed `npm-debug.log` from the `.gitignore` file as npm [v4.2.0](https://github.com/npm/npm/releases/tag/v4.2.0)+ doesn’t create it in the current working directory. + +## 11.0.2 + +### Patch + +- Updated dependencies. +- Simplified the GitHub Actions CI config with the [`npm install-test`](https://docs.npmjs.com/cli/v7/commands/npm-install-test) command. +- Use Babel config `overrides` to ensure `.js` files are parsed as scripts, eliminating Babel `interopRequireDefault` helpers from transpilation output. +- Updated Zeit/Vercel related URLs in documentation. +- Updated the readme “Apollo comparison” section. + +## 11.0.1 + +### Patch + +- Updated Node.js support to `^10.17.0 || ^12.0.0 || >= 13.7.0`. This is only a correction; the dependency updates with breaking changes happened in previous versions. +- Updated dependencies. +- Simplified JSX boolean props in tests. +- Improved event documentation. +- Fixed an incorrect `reportCacheErrors` JSDoc parameter type. +- Updated EditorConfig. + +## 11.0.0 + +### Major + +- Added a package [`exports`](https://nodejs.org/api/packages.html#packages_exports) field to support native ESM in Node.js. +- Some source and published files are now `.js` (CJS) instead of `.mjs` (ESM), so undocumented deep imports may no longer work. [This approach avoids the dual package hazard](https://nodejs.org/api/packages.html#packages_approach_1_use_an_es_module_wrapper). +- Updated Node.js support from v10+ to `10 - 12 || >= 13.7` to reflect the package `exports` related breaking changes. + +### Patch + +- Updated dependencies. +- Added a new [`babel-plugin-transform-runtime-file-extensions`](https://npm.im/babel-plugin-transform-runtime-file-extensions) dev dependency to simplify Babel config. +- Improved the package `prepare:prettier` and `test:prettier` scripts. +- Reordered the package `test:eslint` script args for consistency with `test:prettier`. +- Configured Prettier option `semi` to the default, `true`. +- Reconfigured [`size-limit`](https://npm.im/size-limit): + - Separately test the universal and server only exports, without using unpublished size limit entry files that bloat the measured sizes. + - Separately test the ESM and CJS exports. + - Separately limit tests, with the universal ESM and CJS set to a 3 KB maximum size. +- Removed redundant ESLint disable comments. +- Also run GitHub Actions with Node.js v14. +- Updated readme content. +- Updated JSDoc code examples: + - Prettier formatting. + - Import React in examples containing JSX. + - Use Node.js ESM compatible import specifiers. + +## 10.0.0 + +### Major + +- Updated Node.js support from v8.10+ to v10+. +- Updated dependencies, some of which require Node.js v10+. +- Replaced the [`tap`](https://npm.im/tap) dev dependency with [`test-director`](https://npm.im/test-director) and [`hard-rejection`](https://npm.im/hard-rejection), and refactored tests accordingly. This improves the dev experience and reduced the dev install size by ~75.5 MB. +- Use `ReactDOM.unstable_batchedUpdates` in the `useGraphQL` React hook to reduce the number of renders when loading completes, fixing [#38](https://github.com/jaydenseric/graphql-react/issues/38) via [#42](https://github.com/jaydenseric/graphql-react/pull/42). Although [`react-dom`](https://npm.im/react-dom) was already a peer dependency, this is the first time it's being used in the client API; potentially a breaking change for atypical projects. + +### Patch + +- Updated tests for compatibility with updated dependencies. +- Removed the [`object-assign`](https://npm.im/object-assign) dependency and several Babel dev dependencies after simplifying the Babel config. +- Added a new [`babel-plugin-transform-require-extensions`](https://npm.im/babel-plugin-transform-require-extensions) dev dependency and ensured ESM import specifiers in both source and published `.mjs` files contain file names with extensions, which [are mandatory in the final Node.js ESM implementation](https://nodejs.org/api/esm.html#esm_mandatory_file_extensions). Published CJS `.js` files now also have file extensions in `require` paths. +- Stop using [`husky`](https://npm.im/husky) and [`lint-staged`](https://npm.im/lint-staged). +- Lint fixes for [`prettier`](https://npm.im/prettier) v2. +- Tidied Babel configs. +- Ensure GitHub Actions run on pull request. +- Use strict mode for scripts. +- Readme “Apollo comparison” section corrections and tweaks. + +## 9.1.0 + +### Minor + +- Setup [GitHub Sponsors funding](https://github.com/sponsors/jaydenseric): + - Added `.github/funding.yml` to display a sponsor button in GitHub. + - Added a `package.json` `funding` field to enable npm CLI funding features. + +### Patch + +- Updated dev dependencies. + ## 9.0.0 ### Major @@ -35,7 +475,7 @@ - Updated dependencies. - Increased the post SSR hydration time from 500 to 1000 milliseconds, closing [#37](https://github.com/jaydenseric/graphql-react/issues/37). - Added a `useGraphQL` options guide for common situations. -- Test `GraphQL.operate()` with both `reloadOnLoad` and `resetOnLoad` options true. +- Test the `GraphQL` instance method `operate` with both `reloadOnLoad` and `resetOnLoad` options `true`. - Use string `FormData` field names, as some `FormData` polyfills don't coerce numbers like native implementations do. - Test files in variables result in appropriate fetch options for a valid [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec). - Tidied test names. @@ -177,7 +617,7 @@ - Handle exceptions outside tests (see [tapjs/node-tap#463 (comment)](https://github.com/tapjs/node-tap/issues/463#issuecomment-456701261)). - Added a `ReactNode` JSDoc type, replacing `ReactElement` types. - Removed tests made redundant by the removal of the `preload` function. -- Document [the official Next.js example](https://github.com/zeit/next.js/tree/canary/examples/with-graphql-react). +- Document [the official Next.js example](https://github.com/vercel/next.js/tree/canary/examples/with-graphql-react). - Improved documentation. ## 6.0.1 diff --git a/fetchGraphQL.mjs b/fetchGraphQL.mjs new file mode 100644 index 0000000..7bd5920 --- /dev/null +++ b/fetchGraphQL.mjs @@ -0,0 +1,195 @@ +// @ts-check + +/** + * @import { CacheValue } from "./Cache.mjs" + * @import { + * GraphQLResult, + * GraphQLResultError, + * GraphQLResultErrorLoadingFetch, + * GraphQLResultErrorResponseHttpStatus, + * GraphQLResultErrorResponseJsonParse, + * GraphQLResultErrorResponseMalformed, + * } from "./types.mjs" + */ + +const ERROR_CODE_FETCH_ERROR = "FETCH_ERROR"; +const ERROR_CODE_RESPONSE_HTTP_STATUS = "RESPONSE_HTTP_STATUS"; +const ERROR_CODE_RESPONSE_JSON_PARSE_ERROR = "RESPONSE_JSON_PARSE_ERROR"; +const ERROR_CODE_RESPONSE_MALFORMED = "RESPONSE_MALFORMED"; + +/** + * Fetches a GraphQL operation, always resolving a + * {@link GraphQLResult GraphQL result} suitable for use as a + * {@link CacheValue cache value}, even if there are + * {@link FetchGraphQLResultError errors}. + * @param {string} fetchUri Fetch URI for the GraphQL API. + * @param {RequestInit} [fetchOptions] Fetch options. + * @returns {Promise} Resolves a result suitable for use as + * a {@link CacheValue cache value}. Shouldn’t reject. + * @see [MDN `fetch` parameters docs](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * @see [Polyfillable `fetch` options](https://github.github.io/fetch/#options). + * Don’t use other options if `fetch` is polyfilled for Node.js or legacy + * browsers. + */ +export default function fetchGraphQL(fetchUri, fetchOptions) { + /** @type {FetchGraphQLResult} */ + const result = {}; + + /** @type {Array} */ + const resultErrors = []; + + const fetcher = + typeof fetch === "function" + ? fetch + : () => Promise.reject(new TypeError("Global `fetch` API unavailable.")); + + return fetcher(fetchUri, fetchOptions) + .then( + // Fetch ok. + (response) => { + // Allow the response to be read in the cache value, but prevent it from + // serializing to JSON when sending SSR cache to the client for + // hydration. + Object.defineProperty(result, "response", { value: response }); + + if (!response.ok) + resultErrors.push( + /** @type {GraphQLResultErrorResponseHttpStatus} */ ({ + message: `HTTP ${response.status} status.`, + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_HTTP_STATUS, + statusCode: response.status, + statusText: response.statusText, + }, + }), + ); + + return response.json().then( + // Response JSON parse ok. + (json) => { + // It’s not safe to assume that the response data format conforms to + // the GraphQL spec. + // https://spec.graphql.org/October2021/#sec-Response-Format + + if (typeof json !== "object" || !json || Array.isArray(json)) + resultErrors.push( + /** @type {GraphQLResultErrorResponseMalformed}*/ ({ + message: "Response JSON isn’t an object.", + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_MALFORMED, + }, + }), + ); + else { + const hasErrors = "errors" in json; + const hasData = "data" in json; + + if (!hasErrors && !hasData) + resultErrors.push( + /** @type {GraphQLResultErrorResponseMalformed}*/ ({ + message: + "Response JSON is missing an `errors` or `data` property.", + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_MALFORMED, + }, + }), + ); + else { + // The `errors` field should be an array, or not set. + // https://spec.graphql.org/October2021/#sec-Errors + if (hasErrors) + if (!Array.isArray(json.errors)) + resultErrors.push( + /** @type {GraphQLResultErrorResponseMalformed}*/ ({ + message: + "Response JSON `errors` property isn’t an array.", + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_MALFORMED, + }, + }), + ); + else resultErrors.push(...json.errors); + + // The `data` field should be an object, null, or not set. + // https://spec.graphql.org/October2021/#sec-Data + if (hasData) + if ( + // Note that `null` is an object. + typeof json.data !== "object" || + Array.isArray(json.data) + ) + resultErrors.push( + /** @type {GraphQLResultErrorResponseMalformed}*/ ({ + message: + "Response JSON `data` property isn’t an object or null.", + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_MALFORMED, + }, + }), + ); + else result.data = json.data; + } + } + }, + + // Response JSON parse error. + ({ message }) => { + resultErrors.push( + /** @type {GraphQLResultErrorResponseJsonParse} */ ({ + message: "Response JSON parse error.", + extensions: { + client: true, + code: ERROR_CODE_RESPONSE_JSON_PARSE_ERROR, + jsonParseErrorMessage: message, + }, + }), + ); + }, + ); + }, + + // Fetch error. + ({ message }) => { + resultErrors.push( + /** @type {GraphQLResultErrorLoadingFetch} */ ({ + message: "Fetch error.", + extensions: { + client: true, + code: ERROR_CODE_FETCH_ERROR, + fetchErrorMessage: message, + }, + }), + ); + }, + ) + .then(() => { + if (resultErrors.length) result.errors = resultErrors; + return result; + }); +} + +/** + * {@linkcode fetchGraphQL} {@link GraphQLResult GraphQL result}. + * @typedef {GraphQLResult} FetchGraphQLResult + */ + +/** + * {@linkcode fetchGraphQL} {@link GraphQLResult.errors GraphQL result error}. + * @typedef {FetchGraphQLResultErrorLoading + * | GraphQLResultError} FetchGraphQLResultError + */ + +/** + * {@linkcode fetchGraphQL} {@link GraphQLResult.errors GraphQL result error} + * that’s generated on the client, not the GraphQL server. + * @typedef {GraphQLResultErrorLoadingFetch + * | GraphQLResultErrorResponseHttpStatus + * | GraphQLResultErrorResponseJsonParse + * | GraphQLResultErrorResponseMalformed + * } FetchGraphQLResultErrorLoading + */ diff --git a/fetchGraphQL.test.mjs b/fetchGraphQL.test.mjs new file mode 100644 index 0000000..a4436cd --- /dev/null +++ b/fetchGraphQL.test.mjs @@ -0,0 +1,1006 @@ +// @ts-check + +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import revertableGlobals from "revertable-globals"; + +import fetchGraphQL from "./fetchGraphQL.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; + +describe( + "Function `fetchGraphQL`.", + { + // Some of the tests temporarily modify the global `fetch`. + concurrency: false, + }, + () => { + /** @type {ResponseInit} */ + const graphqlResponseOptions = { + status: 200, + headers: { + "Content-Type": "application/graphql+json", + }, + }; + + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./fetchGraphQL.mjs", import.meta.url), + 600, + ); + }); + + it("Global `fetch` API unavailable.", async () => { + const revertGlobals = revertableGlobals({ + fetch: undefined, + }); + + try { + deepStrictEqual(await fetchGraphQL("http://localhost"), { + errors: [ + { + message: "Fetch error.", + extensions: { + client: true, + code: "FETCH_ERROR", + fetchErrorMessage: "Global `fetch` API unavailable.", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Fetch error.", async () => { + let fetchedUri; + let fetchedOptions; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchErrorMessage = "Message."; + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + + throw new Error(fetchErrorMessage); + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + deepStrictEqual(result, { + errors: [ + { + message: "Fetch error.", + extensions: { + client: true, + code: "FETCH_ERROR", + fetchErrorMessage, + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON parse error, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const responseJson = "{"; + const fetchResponse = new Response(responseJson, graphqlResponseOptions); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + /** + * The JSON parse error message. It’s not hardcoded in case it differs + * across environments. + */ + let jsonParseErrorMessage; + + try { + JSON.parse(responseJson); + } catch (error) { + ok(error instanceof Error); + jsonParseErrorMessage = error.message; + } + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: "Response JSON parse error.", + extensions: { + client: true, + code: "RESPONSE_JSON_PARSE_ERROR", + jsonParseErrorMessage, + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON parse error, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const responseJson = "{"; + const fetchResponse = new Response(responseJson, { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + /** + * The JSON parse error message. It’s not hardcoded in case it differs + * across environments. + */ + let jsonParseErrorMessage; + + try { + JSON.parse(responseJson); + } catch (error) { + ok(error instanceof Error); + jsonParseErrorMessage = error.message; + } + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + { + message: "Response JSON parse error.", + extensions: { + client: true, + code: "RESPONSE_JSON_PARSE_ERROR", + jsonParseErrorMessage, + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON not an object, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response("null", graphqlResponseOptions); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: "Response JSON isn’t an object.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON not an object, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response("null", { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + { + message: "Response JSON isn’t an object.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON missing an `errors` or `data` property, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response("{}", graphqlResponseOptions); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: + "Response JSON is missing an `errors` or `data` property.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON missing an `errors` or `data` property, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response("{}", { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + { + message: + "Response JSON is missing an `errors` or `data` property.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property not an array, no `data` property, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response( + JSON.stringify({ errors: null }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: "Response JSON `errors` property isn’t an array.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property not an array, no `data` property, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response(JSON.stringify({ errors: null }), { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchedResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + { + message: "Response JSON `errors` property isn’t an array.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("No response JSON `errors` property, `data` property not an object, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response( + JSON.stringify({ data: true }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: "Response JSON `data` property isn’t an object or null.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property containing an error, no `data` property, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const errors = [{ message: "Unauthorized." }]; + const fetchResponse = new Response( + JSON.stringify({ errors }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { errors }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property containing an error, no `data` property, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const error = { message: "Unauthorized." }; + const fetchResponse = new Response(JSON.stringify({ errors: [error] }), { + ...graphqlResponseOptions, + status: 401, + statusText: "Unauthorized", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + error, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property containing an error, `data` property populated, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const errors = [ + { + message: 'Cannot query field "b" on type "Query".', + locations: [{ line: 1, column: 5 }], + }, + ]; + const data = { a: true }; + const fetchResponse = new Response( + JSON.stringify({ errors, data }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { errors, data }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON `errors` property containing an error, `data` property populated, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const error = { + message: 'Cannot query field "b" on type "Query".', + locations: [{ line: 1, column: 5 }], + }; + const data = { a: true }; + const fetchResponse = new Response( + JSON.stringify({ errors: [error], data }), + { + ...graphqlResponseOptions, + status: 400, + statusText: "Bad Request", + }, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + error, + ], + data, + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON no `errors` property, `data` property not an object or null, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response( + JSON.stringify({ data: true }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: "Response JSON `data` property isn’t an object or null.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON no `errors` property, `data` property not an object or null, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const fetchResponse = new Response(JSON.stringify({ data: true }), { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + { + message: "Response JSON `data` property isn’t an object or null.", + extensions: { + client: true, + code: "RESPONSE_MALFORMED", + }, + }, + ], + }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON no `error` property, `data` property populated, HTTP status ok.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const data = { a: true }; + const fetchResponse = new Response( + JSON.stringify({ data }), + graphqlResponseOptions, + ); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { data }); + } finally { + revertGlobals(); + } + }); + + it("Response JSON no `error` property, `data` property populated, HTTP status error.", async () => { + let fetchedUri; + let fetchedOptions; + let fetchedResponse; + + const fetchUri = "http://localhost"; + const fetchOptions = {}; + const data = { a: true }; + const fetchResponse = new Response(JSON.stringify({ data }), { + ...graphqlResponseOptions, + status: 500, + statusText: "Internal Server Error", + }); + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + * @returns {Promise} Response. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + fetchedResponse = fetchResponse; + + return fetchResponse; + }, + }); + + try { + const result = await fetchGraphQL(fetchUri, fetchOptions); + + strictEqual(fetchedUri, fetchUri); + strictEqual(fetchedOptions, fetchOptions); + strictEqual(fetchedResponse, fetchResponse); + strictEqual(result.response, fetchResponse); + deepStrictEqual(result, { + errors: [ + { + message: `HTTP ${fetchResponse.status} status.`, + extensions: { + client: true, + code: "RESPONSE_HTTP_STATUS", + statusCode: fetchResponse.status, + statusText: fetchResponse.statusText, + }, + }, + ], + data, + }); + } finally { + revertGlobals(); + } + }); + }, +); diff --git a/fetchOptionsGraphQL.mjs b/fetchOptionsGraphQL.mjs new file mode 100644 index 0000000..1880ddd --- /dev/null +++ b/fetchOptionsGraphQL.mjs @@ -0,0 +1,70 @@ +// @ts-check + +/** @import { GraphQLOperation } from "./types.mjs" */ + +import extractFiles from "extract-files/extractFiles.mjs"; +import isExtractableFile from "extract-files/isExtractableFile.mjs"; + +/** + * Creates default {@link RequestInit `fetch` options} for a + * {@link GraphQLOperation GraphQL operation}. If the operation contains files + * to upload, the options will be for a + * [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec), + * otherwise they will be for a regular + * [GraphQL `POST` request](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#post). + * + * This utility exists for convenience in projects and isn’t used directly by + * this library. Avoid using it if there’s no chance the operation contains + * files. + * @param {GraphQLOperation} operation GraphQL operation. + * @returns {RequestInit} [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options. + */ +export default function fetchOptionsGraphQL(operation) { + /** @type {RequestInit} */ + const fetchOptions = { + method: "POST", + headers: { + Accept: "application/json", + }, + }; + + const { clone, files } = extractFiles(operation, isExtractableFile); + const operationJSON = JSON.stringify(clone); + + if (files.size) { + // See the GraphQL multipart request spec: + // https://github.com/jaydenseric/graphql-multipart-request-spec + + const form = new FormData(); + + form.set("operations", operationJSON); + + /** @type {{ [formFieldName: string]: Array }} */ + const map = {}; + + let i = 0; + files.forEach((paths) => { + map[++i] = paths; + }); + form.set("map", JSON.stringify(map)); + + i = 0; + files.forEach((paths, file) => { + form.set( + `${++i}`, + file, + // @ts-ignore It’s ok for `name` to be undefined for a `Blob` instance. + file.name, + ); + }); + + fetchOptions.body = form; + } else { + /** @type {{ [headerName: string]: string }} */ (fetchOptions.headers)[ + "Content-Type" + ] = "application/json"; + fetchOptions.body = operationJSON; + } + + return fetchOptions; +} diff --git a/fetchOptionsGraphQL.test.mjs b/fetchOptionsGraphQL.test.mjs new file mode 100644 index 0000000..b552b4a --- /dev/null +++ b/fetchOptionsGraphQL.test.mjs @@ -0,0 +1,57 @@ +// @ts-check + +import "./test/polyfillFile.mjs"; + +import { deepStrictEqual, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import fetchOptionsGraphQL from "./fetchOptionsGraphQL.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; + +describe("Function `fetchOptionsGraphQL`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./fetchOptionsGraphQL.mjs", import.meta.url), + 800, + ); + }); + + it("Without files.", () => { + deepStrictEqual(fetchOptionsGraphQL({ query: "" }), { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: '{"query":""}', + }); + }); + + it("With files.", () => { + const fileName = "a.txt"; + const options = fetchOptionsGraphQL({ + query: "", + variables: { a: new File(["a"], fileName) }, + }); + + // See the GraphQL multipart request spec: + // https://github.com/jaydenseric/graphql-multipart-request-spec + + strictEqual(options.method, "POST"); + deepStrictEqual(options.headers, { Accept: "application/json" }); + assertInstanceOf(options.body, FormData); + + const formDataEntries = Array.from(options.body.entries()); + + strictEqual(formDataEntries.length, 3); + deepStrictEqual(formDataEntries[0], [ + "operations", + '{"query":"","variables":{"a":null}}', + ]); + deepStrictEqual(formDataEntries[1], ["map", '{"1":["variables.a"]}']); + strictEqual(formDataEntries[2][0], "1"); + assertInstanceOf(formDataEntries[2][1], File); + strictEqual(formDataEntries[2][1].name, fileName); + }); +}); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..7935e73 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "maxNodeModuleJsDepth": 10, + "module": "nodenext", + "noEmit": true, + "strict": true + }, + "typeAcquisition": { + "enable": false + } +} diff --git a/license.md b/license.md new file mode 100644 index 0000000..d21fa1c --- /dev/null +++ b/license.md @@ -0,0 +1,9 @@ +# MIT License + +Copyright Jayden Seric + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/package.json b/package.json index 7e46928..0681bd0 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { "name": "graphql-react", - "version": "9.0.0", - "description": "A GraphQL client for React using modern context and hooks APIs that is lightweight (< 2.5 KB size limited) but powerful; the first Relay and Apollo alternative with server side rendering.", + "version": "20.0.0", + "description": "A GraphQL client for React using modern context and hooks APIs that’s lightweight (< 4 kB) but powerful; the first Relay and Apollo alternative with server side rendering.", "license": "MIT", "author": { "name": "Jayden Seric", "email": "me@jaydenseric.com", "url": "https://jaydenseric.com" }, - "repository": "github:jaydenseric/graphql-react", + "repository": { + "type": "git", + "url": "git+https://github.com/jaydenseric/graphql-react.git" + }, "homepage": "https://github.com/jaydenseric/graphql-react#readme", "bugs": "https://github.com/jaydenseric/graphql-react/issues", + "funding": "https://github.com/sponsors/jaydenseric", "keywords": [ "graphql", "client", @@ -26,75 +30,108 @@ "mjs" ], "files": [ - "universal", - "server" + "Cache.mjs", + "CacheContext.mjs", + "cacheDelete.mjs", + "cacheEntryDelete.mjs", + "cacheEntryPrune.mjs", + "cacheEntrySet.mjs", + "cacheEntryStale.mjs", + "cachePrune.mjs", + "cacheStale.mjs", + "fetchGraphQL.mjs", + "fetchOptionsGraphQL.mjs", + "HYDRATION_TIME_MS.mjs", + "HydrationTimeStampContext.mjs", + "Loading.mjs", + "LoadingCacheValue.mjs", + "LoadingContext.mjs", + "Provider.mjs", + "types.mjs", + "useAutoAbortLoad.mjs", + "useAutoLoad.mjs", + "useCache.mjs", + "useCacheEntry.mjs", + "useCacheEntryPrunePrevention.mjs", + "useForceUpdate.mjs", + "useLoadGraphQL.mjs", + "useLoading.mjs", + "useLoadingEntry.mjs", + "useLoadOnDelete.mjs", + "useLoadOnMount.mjs", + "useLoadOnStale.mjs", + "useWaterfallLoad.mjs" ], - "main": "universal", "sideEffects": false, + "exports": { + "./Cache.mjs": "./Cache.mjs", + "./CacheContext.mjs": "./CacheContext.mjs", + "./cacheDelete.mjs": "./cacheDelete.mjs", + "./cacheEntryDelete.mjs": "./cacheEntryDelete.mjs", + "./cacheEntryPrune.mjs": "./cacheEntryPrune.mjs", + "./cacheEntrySet.mjs": "./cacheEntrySet.mjs", + "./cacheEntryStale.mjs": "./cacheEntryStale.mjs", + "./cachePrune.mjs": "./cachePrune.mjs", + "./cacheStale.mjs": "./cacheStale.mjs", + "./fetchGraphQL.mjs": "./fetchGraphQL.mjs", + "./fetchOptionsGraphQL.mjs": "./fetchOptionsGraphQL.mjs", + "./HYDRATION_TIME_MS.mjs": "./HYDRATION_TIME_MS.mjs", + "./HydrationTimeStampContext.mjs": "./HydrationTimeStampContext.mjs", + "./Loading.mjs": "./Loading.mjs", + "./LoadingCacheValue.mjs": "./LoadingCacheValue.mjs", + "./LoadingContext.mjs": "./LoadingContext.mjs", + "./package.json": "./package.json", + "./Provider.mjs": "./Provider.mjs", + "./types.mjs": "./types.mjs", + "./useAutoAbortLoad.mjs": "./useAutoAbortLoad.mjs", + "./useAutoLoad.mjs": "./useAutoLoad.mjs", + "./useCache.mjs": "./useCache.mjs", + "./useCacheEntry.mjs": "./useCacheEntry.mjs", + "./useCacheEntryPrunePrevention.mjs": "./useCacheEntryPrunePrevention.mjs", + "./useLoadGraphQL.mjs": "./useLoadGraphQL.mjs", + "./useLoading.mjs": "./useLoading.mjs", + "./useLoadingEntry.mjs": "./useLoadingEntry.mjs", + "./useLoadOnDelete.mjs": "./useLoadOnDelete.mjs", + "./useLoadOnMount.mjs": "./useLoadOnMount.mjs", + "./useLoadOnStale.mjs": "./useLoadOnStale.mjs", + "./useWaterfallLoad.mjs": "./useWaterfallLoad.mjs" + }, "engines": { - "node": ">=8.10" + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" }, - "browserslist": "Node >= 8.10, > 0.5%, not OperaMini all, not dead", + "browserslist": "Node 18.18 - 19 and Node < 19, Node 20.9 - 21 and Node < 21, Node >= 22, > 0.5%, not OperaMini all, not dead", "peerDependencies": { - "react": "^16.8.0", - "react-dom": "^16.8.0" + "react": "16.14 - 18" }, "dependencies": { - "@babel/runtime": "^7.5.5", - "extract-files": "^5.0.1", - "fnv1a": "^1.0.1", - "mitt": "^1.1.3", - "object-assign": "^4.1.1", - "prop-types": "^15.7.2" + "extract-files": "^13.0.0", + "react-waterfall-render": "^5.0.0" }, "devDependencies": { - "@babel/cli": "^7.5.5", - "@babel/core": "^7.5.5", - "@babel/plugin-proposal-class-properties": "^7.5.5", - "@babel/plugin-proposal-object-rest-spread": "^7.5.5", - "@babel/plugin-transform-runtime": "^7.5.5", - "@babel/preset-env": "^7.5.5", - "@babel/preset-react": "^7.0.0", - "@size-limit/preset-small-lib": "^2.2.1", - "babel-eslint": "^10.0.2", - "babel-plugin-transform-replace-object-assign": "^2.0.0", - "cross-fetch": "^3.0.4", - "eslint": "^6.1.0", - "eslint-config-env": "^11.0.0", - "eslint-config-prettier": "^6.0.0", - "eslint-plugin-import": "^2.18.2", - "eslint-plugin-import-order-alphabetical": "^1.0.0", - "eslint-plugin-jsdoc": "^18.1.4", - "eslint-plugin-node": "^10.0.0", - "eslint-plugin-prettier": "^3.1.0", - "eslint-plugin-react": "^7.14.3", - "eslint-plugin-react-hooks": "^2.3.0", - "formdata-node": "^1.8.0", - "graphql": "^14.4.2", - "graphql-api-koa": "^2.0.0", - "husky": "^3.0.1", - "jsdoc-md": "^4.0.1", - "koa": "^2.7.0", - "koa-bodyparser": "^4.2.1", - "lint-staged": "^9.2.0", - "prettier": "^1.18.2", - "react": "^16.8.6", - "react-dom": "^16.8.6", - "react-test-renderer": "^16.8.6", - "tap": "^14.10.1" + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/react-test-renderer": "^18.3.0", + "coverage-node": "^8.0.0", + "esbuild": "^0.23.0", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-simple-import-sort": "^12.1.1", + "filter-console": "^1.0.0", + "gzip-size": "^7.0.0", + "prettier": "^3.3.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-test-renderer": "^18.3.1", + "revertable-globals": "^4.0.0", + "typescript": "^5.5.3" }, "scripts": { - "prepare": "npm run prepare:clean && npm run prepare:mjs && npm run prepare:js && npm run prepare:jsdoc && npm run prepare:prettier", - "prepare:clean": "rm -rf universal server test", - "prepare:mjs": "BABEL_ESM=1 babel src -d . --keep-file-extension", - "prepare:js": "babel src -d .", - "prepare:jsdoc": "jsdoc-md", - "prepare:prettier": "prettier '{universal,server,test}/**/*.{mjs,js}' readme.md --write", - "test": "npm run test:eslint && npm run test:prettier && npm run test:tap && npm run test:size", - "test:eslint": "eslint . --ext mjs,js", - "test:prettier": "prettier '**/*.{json,yml,md}' -l", - "test:tap": "tap test/*.{mjs,js}", - "test:size": "size-limit", + "eslint": "eslint .", + "prettier": "prettier -c .", + "types": "tsc -p jsconfig.json", + "tests": "coverage-node --test-reporter=spec --test", + "test": "npm run eslint && npm run prettier && npm run types && npm run tests", "prepublishOnly": "npm test" } } diff --git a/readme.md b/readme.md index f416bee..2e02f71 100644 --- a/readme.md +++ b/readme.md @@ -2,787 +2,206 @@ # graphql-react -[![npm version](https://badgen.net/npm/v/graphql-react)](https://npm.im/graphql-react) [![CI status](https://github.com/jaydenseric/graphql-react/workflows/CI/badge.svg)](https://github.com/jaydenseric/graphql-react/actions) +A [GraphQL](https://graphql.org) client for [React](https://reactjs.org) using modern [context](https://reactjs.org/docs/context) and [hooks](https://reactjs.org/docs/hooks-intro) APIs that’s lightweight (< 4 kB) but powerful; the first [Relay](https://relay.dev) and [Apollo](https://apollographql.com/apollo-client) alternative with server side rendering. -A [GraphQL](https://graphql.org) client for [React](https://reactjs.org) using modern [context](https://reactjs.org/docs/context) and [hooks](https://reactjs.org/docs/hooks-intro) APIs that is lightweight (< 2.5 KB [size limited](https://github.com/ai/size-limit)) but powerful; the first [Relay](https://facebook.github.io/relay) and [Apollo](https://apollographql.com/docs/react) alternative with server side rendering. +The [exports](#exports) can also be used to custom load, cache and server side render any data, even from non-[GraphQL](https://graphql.org) sources. -- [Setup](#setup) -- [Usage](#usage) +- [Installation](#installation) - [Examples](#examples) -- [Support](#support) -- [API](#api) -- [Apollo comparison](#apollo-comparison) +- [Requirements](#requirements) +- [Exports](#exports) -## Setup +## Installation -### Next.js setup - -See the [`next-graphql-react`](https://npm.im/next-graphql-react) setup instructions. - -### Vanilla React setup +> **Note** +> +> For a [Next.js](https://nextjs.org) project, see the [`next-graphql-react`](https://npm.im/next-graphql-react) installation instructions. -To install [`graphql-react`](https://npm.im/graphql-react) from [npm](https://npmjs.com) run: +For [Node.js](https://nodejs.org), to install [`graphql-react`](https://npm.im/graphql-react) and its [`react`](https://npm.im/react) peer dependency with [npm](https://npmjs.com/get-npm), run: ```sh -npm install graphql-react +npm install graphql-react react ``` -Create a single [`GraphQL`](#class-graphql) instance and use [`GraphQLProvider`](#function-graphqlprovider) to provide it for your app. - -For server side rendering see [`ssr()`](#function-ssr). - -## Usage - -Use the [`useGraphQL`](#function-usegraphql) React hook in your components to make queries and mutations, or use the [`GraphQL` instance method `operate`](#graphql-instance-method-operate) directly. - -## Examples - -- [The official Next.js example](https://github.com/zeit/next.js/tree/canary/examples/with-graphql-react). -- [The Next.js example](https://github.com/jaydenseric/graphql-react-examples) deployed at [graphql-react.now.sh](https://graphql-react.now.sh). - -Here is a basic example that displays a Pokemon image, with tips commented: - -```jsx -import { GraphQL, GraphQLProvider, useGraphQL } from 'graphql-react' - -// Zero config GraphQL client that manages the cache. -const graphql = new GraphQL() - -const PokemonImage = ({ name }) => { - // The useGraphQL hook can be used just the same for queries or mutations. - const { loading, cacheValue = {} } = useGraphQL({ - // Any GraphQL API can be queried in components, where fetch options for - // the URL, auth headers, etc. are specified. To avoid repetition it’s a - // good idea to import the fetch options override functions for the APIs - // your app uses from a central module. The default fetch options received - // by the override function are tailored to the operation; typically the - // body is JSON but if there are files in the variables it will be a - // FormData instance for a GraphQL multipart request. - fetchOptionsOverride(options) { - options.url = 'https://graphql-pokemon.now.sh' - }, - - // The operation typically contains `query` and sometimes `variables`, but - // additional properties can be used; all are JSON encoded and sent to the - // GraphQL server in the fetch request body. - operation: { - query: `{ pokemon(name: "${name}") { image } }` - }, - - // Load the query whenever the component mounts. This is desirable for - // queries to display content, but not for on demand situations like - // pagination view more buttons or forms that submit mutations. - loadOnMount: true, - - // Reload the query whenever a global cache reload is signaled. - loadOnReload: true, - - // Reload the query whenever the global cache is reset. Resets immediately - // delete the cache and are mostly only used when logging out the user. - loadOnReset: true - }) - - return cacheValue.data ? ( - {name} - ) : loading ? ( - // Data is often reloaded, so don’t assume loading indicates no data. - 'Loading…' - ) : ( - // Detailed error info is available in the `cacheValue` properties - // `fetchError`, `httpError`, `parseError` and `graphQLErrors`. A combination - // of errors is possible, and an error doesn’t necessarily mean data is - // unavailable. - 'Error!' - ) +For [Deno](https://deno.land) and browsers, an example import map (realistically use 4 import maps, with optimal URLs for server vs client and development vs production): + +```json +{ + "imports": { + "extract-files/": "https://unpkg.com/extract-files@13.0.0/", + "graphql-react/": "https://unpkg.com/graphql-react@20.0.0/", + "is-plain-obj": "https://unpkg.com/is-plain-obj@4.1.0/index.js", + "is-plain-obj/": "https://unpkg.com/is-plain-obj@4.1.0/", + "react": "https://esm.sh/react@18.2.0", + "react-waterfall-render/": "https://unpkg.com/react-waterfall-render@5.0.0/" + } } - -const App = () => ( - - - -) ``` -## Support - -- Node.js v8.10+ -- Browsers [`> 0.5%, not OperaMini all, not dead`](https://browserl.ist/?q=%3E+0.5%25%2C+not+OperaMini+all%2C+not+dead) - -Consider polyfilling: - -- [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise) -- [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) -- [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) - -## API - -### Table of contents - -- [class GraphQL](#class-graphql) - - [GraphQL instance method off](#graphql-instance-method-off) - - [GraphQL instance method on](#graphql-instance-method-on) - - [GraphQL instance method operate](#graphql-instance-method-operate) - - [GraphQL instance method reload](#graphql-instance-method-reload) - - [GraphQL instance method reset](#graphql-instance-method-reset) - - [GraphQL instance property cache](#graphql-instance-property-cache) - - [GraphQL instance property operations](#graphql-instance-property-operations) -- [function GraphQLProvider](#function-graphqlprovider) -- [function reportCacheErrors](#function-reportcacheerrors) -- [function ssr](#function-ssr) -- [function useGraphQL](#function-usegraphql) -- [constant GraphQLContext](#constant-graphqlcontext) -- [type GraphQLCache](#type-graphqlcache) -- [type GraphQLCacheKey](#type-graphqlcachekey) -- [type GraphQLCacheValue](#type-graphqlcachevalue) -- [type GraphQLFetchOptions](#type-graphqlfetchoptions) -- [type GraphQLFetchOptionsOverride](#type-graphqlfetchoptionsoverride) -- [type GraphQLOperation](#type-graphqloperation) -- [type GraphQLOperationLoading](#type-graphqloperationloading) -- [type GraphQLOperationStatus](#type-graphqloperationstatus) -- [type HttpError](#type-httperror) -- [type ReactNode](#type-reactnode) - -### class GraphQL - -A lightweight GraphQL client that caches queries and mutations. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | object? = {} | Options. | -| `options.cache` | [GraphQLCache](#type-graphqlcache)? = {} | Cache to import; usually from a server side render. | - -#### See - -- [`reportCacheErrors`](#function-reportcacheerrors) to setup error reporting. - -#### Examples - -_Construct a GraphQL client._ - -> ```js -> import { GraphQL } from 'graphql-react' -> -> const graphql = new GraphQL() -> ``` - -#### GraphQL instance method off - -Removes an event listener. - -| Parameter | Type | Description | -| :-------- | :------- | :------------- | -| `type` | string | Event type. | -| `handler` | Function | Event handler. | - -#### GraphQL instance method on - -Adds an event listener. - -| Parameter | Type | Description | -| :-------- | :------- | :------------- | -| `type` | string | Event type. | -| `handler` | Function | Event handler. | - -##### See - -- [`reportCacheErrors`](#function-reportcacheerrors) can be used with this to setup error reporting. - -#### GraphQL instance method operate - -Loads or reuses an already loading GraphQL operation in [GraphQL operations](#graphql-instance-property-operations). Emits a [`GraphQL`](#class-graphql) instance `fetch` event if an already loading operation isn’t reused, and a `cache` event once it’s loaded into the [GraphQL cache](#graphql-instance-property-cache). - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | object | Options. | -| `options.operation` | [GraphQLOperation](#type-graphqloperation) | GraphQL operation. | -| `options.fetchOptionsOverride` | [GraphQLFetchOptionsOverride](#type-graphqlfetchoptionsoverride)? | Overrides default GraphQL operation [`fetch` options](#type-graphqlfetchoptions). | -| `options.reloadOnLoad` | boolean? = `false` | Should a [GraphQL reload](#graphql-instance-method-reload) happen after the operation loads, excluding the loaded operation cache. | -| `options.resetOnLoad` | boolean? = `false` | Should a [GraphQL reset](#graphql-instance-method-reset) happen after the operation loads, excluding the loaded operation cache. | - -**Returns:** [GraphQLOperationLoading](#type-graphqloperationloading) — Loading GraphQL operation details. - -#### GraphQL instance method reload - -Signals that [GraphQL cache](#graphql-instance-property-cache) subscribers such as the [`useGraphQL`](#function-usegraphql) React hook should reload their GraphQL operation. Emits a [`GraphQL`](#class-graphql) instance `reload` event. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `exceptCacheKey` | [GraphQLCacheKey](#type-graphqlcachekey)? | A [GraphQL cache](#graphql-instance-property-cache) [key](#type-graphqlcachekey) for cache to exempt from reloading. | - -##### Examples - -_Reloading the [GraphQL cache](#graphql-instance-property-cache)._ - -> ```js -> graphql.reload() -> ``` - -#### GraphQL instance method reset - -Resets the [GraphQL cache](#graphql-instance-property-cache), useful when a user logs out. Emits a [`GraphQL`](#class-graphql) instance `reset` event. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `exceptCacheKey` | [GraphQLCacheKey](#type-graphqlcachekey)? | A [GraphQL cache](#graphql-instance-property-cache) [key](#type-graphqlcachekey) for cache to exempt from deletion. Useful for resetting cache after a mutation, preserving the mutation cache. | - -##### Examples - -_Resetting the [GraphQL cache](#graphql-instance-property-cache)._ - -> ```js -> graphql.reset() -> ``` - -#### GraphQL instance property cache - -Cache of loaded GraphQL operations. You probably don’t need to interact with this unless you’re implementing a server side rendering framework. - -**Type:** [GraphQLCache](#type-graphqlcache) - -##### Examples - -_Export cache as JSON._ - -> ```js -> const exportedCache = JSON.stringify(graphql.cache) -> ``` - -_Example cache JSON._ - -> ```json -> { -> "a1bCd2": { -> "data": { -> "viewer": { -> "name": "Jayden Seric" -> } -> } -> } -> } -> ``` - -#### GraphQL instance property operations - -A map of loading GraphQL operations. You probably don’t need to interact with this unless you’re implementing a server side rendering framework. - -**Type:** object<[GraphQLCacheKey](#type-graphqlcachekey), Promise<[GraphQLCacheValue](#type-graphqlcachevalue)>> - ---- - -### function GraphQLProvider - -A React component that provides a [`GraphQL`](#class-graphql) instance for an app. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `props` | object | Component props. | -| `props.graphql` | [GraphQL](#class-graphql) | [`GraphQL`](#class-graphql) instance. | -| `props.children` | [ReactNode](#type-reactnode)? | React children. | - -**Returns:** [ReactNode](#type-reactnode) — React virtual DOM node. - -#### See - -- [`GraphQLContext`](#constant-graphqlcontext) is provided via this component. -- [`useGraphQL`](#function-usegraphql) React hook requires this component to be an ancestor to work. - -#### Examples - -_Provide a [`GraphQL`](#class-graphql) instance for an app._ - -> ```jsx -> import { GraphQL, GraphQLProvider } from 'graphql-react' -> -> const graphql = new GraphQL() -> -> const App = ({ children }) => ( -> {children} -> ) -> ``` - ---- - -### function reportCacheErrors - -A [`GraphQL`](#class-graphql) `cache` event handler that reports [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API), HTTP, parse and GraphQL errors via `console.log()`. In a browser environment the grouped error details are expandable. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `data` | object | [`GraphQL`](#class-graphql) `cache` event data. | -| `data.cacheKey` | [GraphQLCacheKey](#type-graphqlcachekey) | [GraphQL cache](#graphql-instance-property-cache) [key](#type-graphqlcachekey). | -| `data.cacheValue` | [GraphQLCacheKey](#type-graphqlcachekey) | [GraphQL cache](#graphql-instance-property-cache) [value](#type-graphqlcachevalue). | - -#### Examples - -_[`GraphQL`](#class-graphql) initialized to report cache errors._ - -> ```js -> import { GraphQL, reportCacheErrors } from 'graphql-react' -> -> const graphql = new GraphQL() -> graphql.on('cache', reportCacheErrors) -> ``` - ---- - -### function ssr - -Asynchronously server side renders a [React node](#type-reactnode), preloading all GraphQL queries set to `loadOnMount`. After resolving, cache can be exported from the [`GraphQL` instance property `cache`](#graphql-instance-property-cache) for serialization (usually to JSON) and transport to the client for hydration via the [`GraphQL` constructor parameter `options.cache`](#class-graphql). - -Be sure to globally polyfill [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API). - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `graphql` | [GraphQL](#class-graphql) | [`GraphQL`](#class-graphql) instance. | -| `node` | [ReactNode](#type-reactnode) | React virtual DOM node. | -| `render` | Function? = ReactDOMServer.renderToStaticMarkup | Synchronous React server side render function, defaulting to [`ReactDOMServer.renderToStaticMarkup`](https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup) as it is more efficient than [`ReactDOMServer.renderToString`](https://reactjs.org/docs/react-dom-server.html#rendertostring). | - -**Returns:** Promise<string> — Promise resolving the rendered HTML string. - -#### See - -- [`ReactDOMServer` docs](https://reactjs.org/docs/react-dom-server). -- [`next-graphql-react`](https://npm.im/next-graphql-react) to use this API in a [Next.js](https://nextjs.org) project. - -#### Examples - -_SSR function that resolves a HTML string and cache JSON for client hydration._ - -> ```jsx -> import { GraphQL, GraphQLProvider } from 'graphql-react' -> import { ssr } from 'graphql-react/server' -> import ReactDOMServer from 'react-dom/server' -> import { App } from './components' -> -> async function render() { -> const graphql = new GraphQL() -> const page = ( -> -> -> -> ) -> const html = await ssr(graphql, page, ReactDOMServer.renderToString) -> const cache = JSON.stringify(graphql.cache) -> return { html, cache } -> } -> ``` - -_SSR function that resolves a HTML string suitable for a static page._ - -> ```jsx -> import { GraphQL, GraphQLProvider } from 'graphql-react' -> import { ssr } from 'graphql-react/server' -> import { App } from './components' -> -> function render() { -> const graphql = new GraphQL() -> const page = ( -> -> -> -> ) -> return ssr(graphql, page) -> } -> ``` - ---- - -### function useGraphQL - -A [React hook](https://reactjs.org/docs/hooks-intro) to manage a GraphQL operation in a component. - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | object | Options. | -| `options.fetchOptionsOverride` | [GraphQLFetchOptionsOverride](#type-graphqlfetchoptionsoverride)? | Overrides default [`fetch` options](#type-graphqlfetchoptions) for the GraphQL operation. | -| `options.loadOnMount` | boolean? = `false` | Should the operation load when the component mounts. | -| `options.loadOnReload` | boolean? = `false` | Should the operation load when the [`GraphQL`](#class-graphql) `reload` event fires and there is a [GraphQL cache](#graphql-instance-property-cache) [value](#type-graphqlcachevalue) to reload, but only if the operation was not the one that caused the reload. | -| `options.loadOnReset` | boolean? = `false` | Should the operation load when the [`GraphQL`](#class-graphql) `reset` event fires and the [GraphQL cache](#graphql-instance-property-cache) [value](#type-graphqlcachevalue) is deleted, but only if the operation was not the one that caused the reset. | -| `options.reloadOnLoad` | boolean? = `false` | Should a [GraphQL reload](#graphql-instance-method-reload) happen after the operation loads, excluding the loaded operation cache. | -| `options.resetOnLoad` | boolean? = `false` | Should a [GraphQL reset](#graphql-instance-method-reset) happen after the operation loads, excluding the loaded operation cache. | -| `options.operation` | [GraphQLOperation](#type-graphqloperation) | GraphQL operation. | - -**Returns:** [GraphQLOperationStatus](#type-graphqloperationstatus) — GraphQL operation status. - -#### See - -- [`GraphQLContext`](#constant-graphqlcontext) is required for this hook to work. - -#### Examples - -_A component that displays a Pokémon image._ - -> ```jsx -> import { useGraphQL } from 'graphql-react' -> -> const PokemonImage = ({ name }) => { -> const { loading, cacheValue = {} } = useGraphQL({ -> fetchOptionsOverride(options) { -> options.url = 'https://graphql-pokemon.now.sh' -> }, -> operation: { -> query: `{ pokemon(name: "${name}") { image } }` -> }, -> loadOnMount: true, -> loadOnReload: true, -> loadOnReset: true -> }) -> -> return cacheValue.data ? ( -> {name} -> ) : loading ? ( -> 'Loading…' -> ) : ( -> 'Error!' -> ) -> } -> ``` - -_Options guide for common situations._ - -> | Situation | `loadOnMount` | `loadOnReload` | `loadOnReset` | `reloadOnLoad` | `resetOnLoad` | -> | :-- | :-: | :-: | :-: | :-: | :-: | -> | Profile query | ✔️ | ✔️ | ✔️ | | | -> | Login mutation | | | | | ✔️ | -> | Logout mutation | | | | | ✔️ | -> | Change password mutation | | | | | | -> | Change name mutation | | | | ✔️ | | -> | Like a post mutation | | | | ✔️ | | - ---- - -### constant GraphQLContext - -[React context object](https://reactjs.org/docs/context#api) for a [`GraphQL`](#class-graphql) instance. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `Provider` | Function | [React context provider component](https://reactjs.org/docs/context#contextprovider). | -| `Consumer` | Function | [React context consumer component](https://reactjs.org/docs/context#contextconsumer). | - -#### See - -- [`GraphQLProvider`](#function-graphqlprovider) is used to provide this context. -- [`useGraphQL`](#function-usegraphql) React hook requires an ancestor [`GraphQLContext`](#constant-graphqlcontext) `Provider` to work. - -#### Examples - -_A button component that resets the [GraphQL cache](#graphql-instance-property-cache)._ - -> ```jsx -> import React from 'react' -> import { GraphQLContext } from 'graphql-react' -> -> const ResetCacheButton = () => { -> const graphql = React.useContext(GraphQLContext) -> return -> } -> ``` - ---- - -### type GraphQLCache - -A [GraphQL cache](#graphql-instance-property-cache) map of GraphQL operation results. +These dependencies might not need to be in the import map, depending on what [`graphql-react`](https://npm.im/graphql-react) modules the project imports from: -**Type:** object<[GraphQLCacheKey](#type-graphqlcachekey), [GraphQLCacheValue](#type-graphqlcachevalue)> +- [`extract-files`](https://npm.im/extract-files) +- [`is-plain-obj`](https://npm.im/is-plain-obj) +- [`react-waterfall-render`](https://npm.im/react-waterfall-render) -#### See +Polyfill any required globals (see [_**Requirements**_](#requirements)) that are missing in your server and client environments. -- [`GraphQL`](#class-graphql) constructor accepts this type in `options.cache`. -- [`GraphQL` instance property `cache`](#graphql-instance-property-cache) is this type. +Create a single [`Cache`](./Cache.mjs) instance and use the component [`Provider`](./Provider.mjs) to provide it for your app. ---- +To server side render your app, use the function [`waterfallRender`](https://github.com/jaydenseric/react-waterfall-render#exports) from [`react-waterfall-render`](https://npm.im/react-waterfall-render). -### type GraphQLCacheKey - -A [GraphQL cache](#type-graphqlcache) key, derived from a hash of the [`fetch` options](#type-graphqlfetchoptions) of the GraphQL operation that populated the [value](#type-graphqlcachevalue). - -**Type:** string - ---- - -### type GraphQLCacheValue - -JSON serializable GraphQL operation result that includes errors and data. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `fetchError` | string? | `fetch` error message. | -| `httpError` | [HttpError](#type-httperror)? | `fetch` response HTTP error. | -| `parseError` | string? | Parse error message. | -| `graphQLErrors` | Array<object>? | GraphQL response errors. | -| `data` | object? | GraphQL response data. | - ---- - -### type GraphQLFetchOptions - -GraphQL API URL and [polyfillable `fetch` options](https://github.github.io/fetch/#options). The `url` property gets extracted and the rest are used as [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options. - -**Type:** object - -| Property | Type | Description | -| :------------ | :----------------- | :------------------------------- | -| `url` | string | GraphQL API URL. | -| `body` | string \| FormData | HTTP request body. | -| `headers` | object | HTTP request headers. | -| `credentials` | string? | Authentication credentials mode. | - -#### See - -- [`GraphQLFetchOptionsOverride` functions](#type-graphqlfetchoptionsoverride) accept this type. - ---- - -### type GraphQLFetchOptionsOverride - -Overrides default [GraphQL `fetch` options](#type-graphqlfetchoptions). Mutate the provided options object; there is no need to return it. - -**Type:** Function - -| Parameter | Type | Description | -| :-- | :-- | :-- | -| `options` | [GraphQLFetchOptions](#type-graphqlfetchoptions) | [GraphQL `fetch` options](#type-graphqlfetchoptions) tailored to the [GraphQL operation](#type-graphqloperation), e.g. if there are files to upload `options.body` will be a [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) instance conforming to the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec). | - -#### See - -- [`GraphQL` instance method `operate`](#graphql-instance-method-operate) accepts this type in `options.fetchOptionsOverride`. -- [`useGraphQL`](#function-usegraphql) React hook accepts this type in `options.fetchOptionsOverride`. - -#### Examples - -_Setting [GraphQL `fetch` options](#type-graphqlfetchoptions) for an imaginary API._ - -> ```js -> options => { -> options.url = 'https://api.example.com/graphql' -> options.credentials = 'include' -> } -> ``` - ---- - -### type GraphQLOperation - -A GraphQL operation. Additional properties may be used; all are sent to the GraphQL server. - -**Type:** object - -| Property | Type | Description | -| :---------- | :----- | :----------------------------- | -| `query` | string | GraphQL queries/mutations. | -| `variables` | object | Variables used in the `query`. | - -#### See - -- [`GraphQL` instance method `operate`](#graphql-instance-method-operate) accepts this type in `options.operation`. -- [`useGraphQL`](#function-usegraphql) React hook accepts this type in `options.operation`. - ---- - -### type GraphQLOperationLoading - -A loading GraphQL operation. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `cacheKey` | [GraphQLCacheKey](#type-graphqlcachekey) | [GraphQL cache](#graphql-instance-property-cache) [key](#type-graphqlcachekey). | -| `cacheValue` | [GraphQLCacheValue](#type-graphqlcachevalue)? | [GraphQL cache](#type-graphqlcache) [value](#type-graphqlcachevalue) from the last identical query. | -| `cacheValuePromise` | Promise<[GraphQLCacheValue](#type-graphqlcachevalue)> | Resolves the loaded [GraphQL cache](#type-graphqlcache) [value](#type-graphqlcachevalue). | - -#### See - -- [`GraphQL` instance method `operate`](#graphql-instance-method-operate) returns this type. - ---- - -### type GraphQLOperationStatus - -The status of a GraphQL operation. - -**Type:** object - -| Property | Type | Description | -| :-- | :-- | :-- | -| `load` | Function | Loads the GraphQL operation on demand, updating the [GraphQL cache](#graphql-instance-property-cache). | -| `loading` | boolean | Is the GraphQL operation loading. | -| `cacheKey` | [GraphQLCacheKey](#type-graphqlcachekey) | [GraphQL cache](#graphql-instance-property-cache) [key](#type-graphqlcachekey). | -| `cacheValue` | [GraphQLCacheValue](#type-graphqlcachevalue) | [GraphQL cache](#type-graphqlcache) [value](#type-graphqlcachevalue). | - -#### See - -- [`useGraphQL`](#function-usegraphql) React hook returns this type. - ---- - -### type HttpError - -[`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) HTTP error. - -**Type:** object - -| Property | Type | Description | -| :----------- | :----- | :---------------- | -| `status` | number | HTTP status code. | -| `statusText` | string | HTTP status text. | - ---- - -### type ReactNode - -A React virtual DOM node; anything that can be rendered. - -**Type:** `undefined` | `null` | boolean | number | string | React.Element | Array<[ReactNode](#type-reactnode)> - -## Apollo comparison - -### Bundle impact - -#### graphql-react - -A < 2.5 KB bundle impact is guaranteed by [Size Limit](https://github.com/ai/size-limit) tests. The impact is smaller than the bundle size badge suggests as the internal [`object-assign`](https://npm.im/object-assign) dependency is shared with [`react`](https://npm.im/react). - -| Dependency | Install size | Bundle size | -| --- | --- | --- | -| [`graphql-react`](https://npm.im/graphql-react) | [![graphql-react install size](https://badgen.net/packagephobia/install/graphql-react)](https://packagephobia.now.sh/result?p=graphql-react) | [![graphql-react minzipped size](https://badgen.net/bundlephobia/minzip/graphql-react)](https://bundlephobia.com/result?p=graphql-react) | - -[Tree shaking](https://developer.mozilla.org/docs/Glossary/Tree_shaking) bundlers will eliminate unused exports (perhaps [`reportCacheErrors`](#function-reportcacheerrors)). - -#### Apollo - -Several dependencies must be installed for a minimal Apollo project. - -| Dependency | Install size | Bundle size | -| --- | --- | --- | -| [`apollo-boost`](https://npm.im/apollo-boost) | [![apollo-boost install size](https://badgen.net/packagephobia/install/apollo-boost)](https://packagephobia.now.sh/result?p=apollo-boost) | [![apollo-boost minzipped size](https://badgen.net/bundlephobia/minzip/apollo-boost)](https://bundlephobia.com/result?p=apollo-boost) | -| [`@apollo/react-hooks`](https://npm.im/@apollo/react-hooks) | [![@apollo/react-hooks install size](https://badgen.net/packagephobia/install/@apollo/react-hooks)](https://packagephobia.now.sh/result?p=@apollo/react-hooks) | [![@apollo/react-hooks minzipped size](https://badgen.net/bundlephobia/minzip/@apollo/react-hooks)](https://bundlephobia.com/result?p=@apollo/react-hooks) | -| [`graphql`](https://npm.im/graphql) | [![graphql install size](https://badgen.net/packagephobia/install/graphql)](https://packagephobia.now.sh/result?p=graphql) | [![graphql minzipped size](https://badgen.net/bundlephobia/minzip/graphql)](https://bundlephobia.com/result?p=graphql) | - -[Tree shaking](https://developer.mozilla.org/docs/Glossary/Tree_shaking) bundlers will eliminate unused [`graphql`](https://npm.im/graphql) exports. - -In addition, [fragment matcher](https://www.apollographql.com/docs/react/advanced/fragments#fragment-matcher) config impacts bundle size relative to the number and complexity of schema unions and interfaces; see [**_Cache strategy_**](#cache-strategy). - -### Native ESM - -#### graphql-react - -Supports native ESM via `.mjs` files for Node.js in [`--experimental-modules`](https://nodejs.org/api/esm.html#esm_enabling) mode and [tree shaking](https://developer.mozilla.org/docs/Glossary/Tree_shaking) bundlers like [webpack](https://webpack.js.org). For legacy environments CJS is provided via `.js` files. - -#### Apollo - -No support for native ESM, although they do provide faux ESM via package `module` fields for [tree shaking](https://developer.mozilla.org/docs/Glossary/Tree_shaking) bundlers like [webpack](https://webpack.js.org). - -### Writing queries - -#### graphql-react - -Uses template strings: - -```js -const QUERY = /* GraphQL */ ` - { - viewer { - id - } - } -` -``` - -The optional `/* GraphQL */` comment signals the syntax for highlighters and linters. - -#### Apollo +## Examples -Uses template strings tagged with `gql` from [`graphql-tag`](https://npm.im/graphql-tag): +- [`graphql-react` examples repo](https://github.com/jaydenseric/graphql-react-examples), a [Deno](https://deno.land) [Ruck](https://ruck.tech) web app deployed at [graphql-react-examples.fly.dev](https://graphql-react-examples.fly.dev). +- [Official Next.js example](https://github.com/vercel/next.js/tree/canary/examples/with-graphql-react) (often outdated as the Next.js team can be extremely slow to review and merge pull requests). -```js -import gql from 'graphql-tag' +Here is a basic example using the [GitHub GraphQL API](https://docs.github.com/en/graphql), with tips commented: -const QUERY = gql` - { - viewer { - id +```jsx +import useAutoLoad from "graphql-react/useAutoLoad.mjs"; +import useCacheEntry from "graphql-react/useCacheEntry.mjs"; +import useLoadGraphQL from "graphql-react/useLoadGraphQL.mjs"; +import useWaterfallLoad from "graphql-react/useWaterfallLoad.mjs"; +import React from "react"; + +// The query is just a string; no need to use `gql` from `graphql-tag`. The +// special comment before the string allows editor syntax highlighting, Prettier +// formatting and linting. The cache system doesn’t require `__typename` or `id` +// fields to be queried. +const query = /* GraphQL */ ` + query ($repoId: ID!) { + repo: node(id: $repoId) { + ... on Repository { + stargazers { + totalCount + } + } } } -` +`; + +export default function GitHubRepoStars({ repoId }) { + const cacheKey = `GitHubRepoStars-${repoId}`; + const cacheValue = useCacheEntry(cacheKey); + + // A hook for loading GraphQL is available, but custom hooks for loading non + // GraphQL (e.g. fetching from a REST API) can be made. + const loadGraphQL = useLoadGraphQL(); + + const load = React.useCallback( + () => + // To be DRY, utilize a custom hook for each API your app loads from, e.g. + // `useLoadGraphQLGitHub`. + loadGraphQL( + cacheKey, + // Fetch URI. + "https://api.github.com/graphql", + // Fetch options. + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}`, + }, + body: JSON.stringify({ + query, + variables: { + repoId, + }, + }), + }, + ), + [cacheKey, loadGraphQL, repoId], + ); + + // This hook automatically keeps the cache entry loaded from when the + // component mounts, reloading it if it’s staled or deleted. It also aborts + // loading if the arguments change or the component unmounts; very handy for + // auto-suggest components! + useAutoLoad(cacheKey, load); + + // Waterfall loading can be used to load data when server side rendering, + // enabled automagically by `next-graphql-react`. To learn how this works or + // to set it up for a non-Next.js app, see: + // https://github.com/jaydenseric/react-waterfall-render + const isWaterfallLoading = useWaterfallLoad(cacheKey, load); + + // When waterfall loading it’s efficient to skip rendering, as the app will + // re-render once this step of the waterfall has loaded. If more waterfall + // loading happens in children, those steps of the waterfall are awaited and + // the app re-renders again, and so forth until there’s no more loading for + // the final server side render. + return isWaterfallLoading + ? null + : cacheValue + ? cacheValue.errors + ? // Unlike many other GraphQL libraries, detailed loading errors are + // cached and can be server side rendered without causing a + // server/client HTML mismatch error. + "Error!" + : cacheValue.data.repo.stargazers.totalCount + : // In this situation no cache value implies loading. Use the + // `useLoadingEntry` hook to manage loading in detail. + "Loading…"; +} ``` -### Cache strategy - -#### graphql-react - -The [`GraphQL`](#class-graphql) client has no GraphQL API specific config; [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options are determined on demand at the component level. Multiple GraphQL APIs can be queried! - -GraphQL operations are cached under hashes of their [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options. Multiple operations with the same hash share the same loading status and cache value. - -[`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API), HTTP, parse and GraphQL errors can be cached, and therefore server side rendered and transported to the client for hydration and initial render. - -#### Apollo +## Requirements -Apollo Client is configured for one GraphQL API per app. +Supported runtime environments: -GraphQL operation data is deconstructed based upon `id` and `__typename` fields into a “[normalized](https://apollographql.com/docs/react/advanced/caching#normalization)” cache. These fields must be queried even if they aren’t used in components. +- [Node.js](https://nodejs.org) versions `^18.18.0 || ^20.9.0 || >=22.0.0`. +- [Deno](https://deno.land), importing from a CDN that might require an import map for dependencies. +- Browsers matching the [Browserslist](https://browsersl.ist) query [`> 0.5%, not OperaMini all, not dead`](https://browsersl.ist/?q=%3E+0.5%25%2C+not+OperaMini+all%2C+not+dead). -[Errors aren’t cached](https://github.com/apollographql/apollo-client/issues/3897#issuecomment-432982170), and therefore can’t be server side rendered and transported to the client for hydration and initial render. - -Apollo Client must be configured with schema knowledge extracted at build time for a “[fragment matcher](https://apollographql.com/docs/react/advanced/fragments#fragment-matcher)” to cache fragments on unions and interfaces properly. It’s challenging to reconfigure and redeploy clients whenever the GraphQL schema updates. Also, the config increases the client bundle size; see [**_Bundle impact_**](#bundle-impact). - -### Stale cache - -#### graphql-react - -By default, cache is refreshed for mounting components. - -GraphQL operations can optionally refresh all cache except their own fresh cache; handy for mutations. - -#### Apollo - -By default, cache isn’t refreshed for mounting components. - -GraphQL mutations only update the cache with the contents of their payload. The prescribed approach is to try to manually update other normalized cache after mutations using complicated and often buggy APIs. Resetting all cache is possible, but it also wipes the result of the last operation. - -### File uploads - -#### graphql-react - -Out of the box file uploads compliant with the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec) (authored by [@jaydenseric](https://github.com/jaydenseric)) which is supported by popular GraphQL servers including [Apollo Server](https://apollographql.com/docs/apollo-server). File input values can be used as query or mutation arguments. - -#### Apollo - -Supports file uploads if you drop [`apollo-boost`](https://npm.im/apollo-boost) and manually setup Apollo Client with [`apollo-upload-client`](https://npm.im/apollo-upload-client) (also by [@jaydenseric](https://github.com/jaydenseric)). - -### Subscriptions - -#### graphql-react - -Not supported yet. - -#### Apollo - -Supported. - -### TypeScript - -#### graphql-react - -Written in ECMAScript; no types are exported. - -#### Apollo - -Written in TypeScript; types are exported. - -### Next.js integration - -#### graphql-react - -Has [an official example](https://github.com/zeit/next.js/tree/canary/examples/with-graphql-react) using [`next-graphql-react`](https://npm.im/next-graphql-react), which provides easy an easy to install [`App`](https://nextjs.org/docs/#custom-app) decorator and [plugin](https://nextjs.org/docs/#custom-configuration) to enable server side rendered GraphQL queries. - -#### Apollo +Consider polyfilling: -Has [an official example](https://github.com/zeit/next.js/tree/canary/examples/with-apollo), but it consists of over 100 lines of complicated copy-paste boilerplate code across multiple files. +- [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) +- [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) +- [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event) +- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) +- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) +- [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) +- [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Window/performance) + +Non [Deno](https://deno.land) projects must configure [TypeScript](https://typescriptlang.org) to use types from the ECMAScript modules that have a `// @ts-check` comment: + +- [`compilerOptions.allowJs`](https://typescriptlang.org/tsconfig#allowJs) should be `true`. +- [`compilerOptions.maxNodeModuleJsDepth`](https://typescriptlang.org/tsconfig#maxNodeModuleJsDepth) should be reasonably large, e.g. `10`. +- [`compilerOptions.module`](https://typescriptlang.org/tsconfig#module) should be `"node16"` or `"nodenext"`. + +## Exports + +The [npm](https://npmjs.com) package [`graphql-react`](https://npm.im/graphql-react) features [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). It doesn’t have a main index module, so use deep imports from the ECMAScript modules that are exported via the [`package.json`](./package.json) field [`exports`](https://nodejs.org/api/packages.html#exports): + +- [`Cache.mjs`](./Cache.mjs) +- [`CacheContext.mjs`](./CacheContext.mjs) +- [`cacheDelete.mjs`](./cacheDelete.mjs) +- [`cacheEntryDelete.mjs`](./cacheEntryDelete.mjs) +- [`cacheEntryPrune.mjs`](./cacheEntryPrune.mjs) +- [`cacheEntrySet.mjs`](./cacheEntrySet.mjs) +- [`cacheEntryStale.mjs`](./cacheEntryStale.mjs) +- [`cachePrune.mjs`](./cachePrune.mjs) +- [`cacheStale.mjs`](./cacheStale.mjs) +- [`fetchGraphQL.mjs`](./fetchGraphQL.mjs) +- [`fetchOptionsGraphQL.mjs`](./fetchOptionsGraphQL.mjs) +- [`HYDRATION_TIME_MS.mjs`](./HYDRATION_TIME_MS.mjs) +- [`HydrationTimeStampContext.mjs`](./HydrationTimeStampContext.mjs) +- [`Loading.mjs`](./Loading.mjs) +- [`LoadingCacheValue.mjs`](./LoadingCacheValue.mjs) +- [`LoadingContext.mjs`](./LoadingContext.mjs) +- [`Provider.mjs`](./Provider.mjs) +- [`types.mjs`](./types.mjs) +- [`useAutoAbortLoad.mjs`](./useAutoAbortLoad.mjs) +- [`useAutoLoad.mjs`](./useAutoLoad.mjs) +- [`useCache.mjs`](./useCache.mjs) +- [`useCacheEntry.mjs`](./useCacheEntry.mjs) +- [`useCacheEntryPrunePrevention.mjs`](./useCacheEntryPrunePrevention.mjs) +- [`useLoadGraphQL.mjs`](./useLoadGraphQL.mjs) +- [`useLoading.mjs`](./useLoading.mjs) +- [`useLoadingEntry.mjs`](./useLoadingEntry.mjs) +- [`useLoadOnDelete.mjs`](./useLoadOnDelete.mjs) +- [`useLoadOnMount.mjs`](./useLoadOnMount.mjs) +- [`useLoadOnStale.mjs`](./useLoadOnStale.mjs) +- [`useWaterfallLoad.mjs`](./useWaterfallLoad.mjs") diff --git a/size-limit-entries/browser.mjs b/size-limit-entries/browser.mjs deleted file mode 100644 index d4b15ec..0000000 --- a/size-limit-entries/browser.mjs +++ /dev/null @@ -1,7 +0,0 @@ -export { - GraphQL, - GraphQLContext, - GraphQLProvider, - useGraphQL, - reportCacheErrors -} from '..' diff --git a/size-limit-entries/server.mjs b/size-limit-entries/server.mjs deleted file mode 100644 index f304364..0000000 --- a/size-limit-entries/server.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export { - GraphQL, - GraphQLContext, - GraphQLProvider, - useGraphQL, - reportCacheErrors -} from '..' -export { ssr } from '../server' diff --git a/src/server/.babelrc.js b/src/server/.babelrc.js deleted file mode 100644 index 4f2aee8..0000000 --- a/src/server/.babelrc.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - comments: false, - presets: [ - [ - '@babel/env', - { - targets: 'node >= 8.10', - modules: process.env.BABEL_ESM ? false : 'commonjs', - shippedProposals: true, - loose: true - } - ] - ], - plugins: ['@babel/transform-runtime'] -} diff --git a/src/server/index.mjs b/src/server/index.mjs deleted file mode 100644 index 26c04e3..0000000 --- a/src/server/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export { ssr } from './ssr' diff --git a/src/server/ssr.mjs b/src/server/ssr.mjs deleted file mode 100644 index cf64d4c..0000000 --- a/src/server/ssr.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import ReactDOMServer from 'react-dom/server' -import { GraphQL } from '../universal/GraphQL' - -/** - * Asynchronously server side renders a [React node]{@link ReactNode}, - * preloading all GraphQL queries set to `loadOnMount`. After resolving, cache - * can be exported from the - * [`GraphQL` instance property `cache`]{@link GraphQL#cache} for serialization - * (usually to JSON) and transport to the client for hydration via the - * [`GraphQL` constructor parameter `options.cache`]{@link GraphQL}. - * - * Be sure to globally polyfill [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API). - * @kind function - * @name ssr - * @param {GraphQL} graphql [`GraphQL`]{@link GraphQL} instance. - * @param {ReactNode} node React virtual DOM node. - * @param {Function} [render=ReactDOMServer.renderToStaticMarkup] Synchronous React server side render function, defaulting to [`ReactDOMServer.renderToStaticMarkup`](https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup) as it is more efficient than [`ReactDOMServer.renderToString`](https://reactjs.org/docs/react-dom-server.html#rendertostring). - * @returns {Promise} Promise resolving the rendered HTML string. - * @see [`ReactDOMServer` docs](https://reactjs.org/docs/react-dom-server). - * @see [`next-graphql-react`](https://npm.im/next-graphql-react) to use this API in a [Next.js](https://nextjs.org) project. - * @example SSR function that resolves a HTML string and cache JSON for client hydration. - * ```jsx - * import { GraphQL, GraphQLProvider } from 'graphql-react' - * import { ssr } from 'graphql-react/server' - * import ReactDOMServer from 'react-dom/server' - * import { App } from './components' - * - * async function render() { - * const graphql = new GraphQL() - * const page = ( - * - * - * - * ) - * const html = await ssr(graphql, page, ReactDOMServer.renderToString) - * const cache = JSON.stringify(graphql.cache) - * return { html, cache } - * } - * ``` - * @example SSR function that resolves a HTML string suitable for a static page. - * ```jsx - * import { GraphQL, GraphQLProvider } from 'graphql-react' - * import { ssr } from 'graphql-react/server' - * import { App } from './components' - * - * function render() { - * const graphql = new GraphQL() - * const page = ( - * - * - * - * ) - * return ssr(graphql, page) - * } - * ``` - */ -export async function ssr( // eslint-disable-line require-await - graphql, - node, - render = ReactDOMServer.renderToStaticMarkup -) { - if (!(graphql instanceof GraphQL)) - throw new Error('ssr() argument 1 must be a GraphQL instance.') - - // Check argument 2 exists, allowing an undefined value as that is a valid - // React node. - if (arguments.length < 2) - throw new Error('ssr() argument 2 must be a React node.') - - if (typeof render !== 'function') - throw new Error('ssr() argument 3 must be a function.') - - // Signal that queries should load at render. - graphql.ssr = true - - /** - * Repeatedly renders the node until all queries within are cached. - * @returns {Promise} Resolves the final rendered HTML string. - * @ignore - */ - async function recurse() { - const string = render(node) - const operations = Object.values(graphql.operations) - - if (operations.length) { - await Promise.all(operations) - return recurse() - } else { - delete graphql.ssr - return string - } - } - - return recurse() -} diff --git a/src/test/.babelrc.js b/src/test/.babelrc.js deleted file mode 100644 index 6e1b9ec..0000000 --- a/src/test/.babelrc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - comments: false, - presets: [ - [ - '@babel/env', - { - targets: { node: true }, - modules: process.env.BABEL_ESM ? false : 'commonjs', - shippedProposals: true, - loose: true - } - ], - '@babel/react' - ], - plugins: ['@babel/transform-runtime'] -} diff --git a/src/test/GraphQL.mjs b/src/test/GraphQL.mjs deleted file mode 100644 index 049306a..0000000 --- a/src/test/GraphQL.mjs +++ /dev/null @@ -1,511 +0,0 @@ -import 'cross-fetch/polyfill' -import { GraphQLInt } from 'graphql' -import Koa from 'koa' -import t from 'tap' -import { GraphQL } from '../universal/GraphQL' -import { createGraphQLKoaApp } from './helpers/createGraphQLKoaApp' -import { promisifyEvent } from './helpers/promisifyEvent' -import { startServer } from './helpers/startServer' - -t.test('GraphQL.cache population via `cache` constructor option', t => { - const cache = { - abcdefg: { - data: { - echo: 'hello' - } - } - } - - const graphql = new GraphQL({ cache }) - - t.deepEquals(graphql.cache, cache, 'GraphQL.cache') - t.end() -}) - -t.test('GraphQL.operate()', async t => { - /** - * Tests [`GraphQL.operate()`]{@link GraphQL#query} under certain conditions. - * @param {object} options Options. - * @param {number} options.port GraphQL server port. - * @param {GraphQLOperation} [options.operation] [GraphQL operation]{@link GraphQLOperation}. - * @param {boolean} [options.resetOnLoad] Should the [GraphQL cache]{@link GraphQL#cache} reset once the query loads. - * @param {GraphQLCache} [options.initialGraphQLCache] Initial [GraphQL cache]{@link GraphQL#cache}. - * @param {GraphQL} [options.graphql] [`GraphQL`]{@link GraphQL} instance. - * @param {GraphQLCacheValue} options.expectedResolvedCacheValue Expected [GraphQL cache]{@link GraphQL#cache} [value]{@link GraphQLCacheValue}. - * @param {Function} [options.callback] Callback that accepts result metadata. - * @returns {Promise} Resolves the test. - * @ignore - */ - const testQuery = ({ - port, - operation = { query: '{ echo }' }, - resetOnLoad, - initialGraphQLCache, - graphql = new GraphQL({ - cache: { - // Spread so that cache updates don’t mutate the original object. - ...initialGraphQLCache - } - }), - expectedResolvedCacheValue, - expectedResponseType = Response, - callback - }) => async t => { - const fetchEvent = promisifyEvent(graphql, 'fetch') - const cacheEvent = promisifyEvent(graphql, 'cache') - if (resetOnLoad) var resetEvent = promisifyEvent(graphql, 'reset') - - const { cacheKey, cacheValue, cacheValuePromise } = graphql.operate({ - fetchOptionsOverride(options) { - options.url = `http://localhost:${port}` - }, - operation, - resetOnLoad - }) - - t.type(cacheKey, 'string', 'cacheKey') - - t.deepEquals( - cacheValue, - initialGraphQLCache ? initialGraphQLCache[cacheKey] : undefined, - 'Initial cache value' - ) - - t.equals(cacheKey in graphql.operations, true, 'graphql.operations key') - t.equals( - graphql.operations[cacheKey], - cacheValuePromise, - 'graphql.operations value' - ) - - const cacheValueResolved = await graphql.operations[cacheKey] - - t.equals( - cacheKey in graphql.operations, - false, - 'graphql.operations no longer contains the cache key' - ) - - t.deepEquals( - cacheValueResolved, - expectedResolvedCacheValue, - 'graphql.operations cache value resolved' - ) - - await t.resolveMatch( - cacheValuePromise, - expectedResolvedCacheValue, - 'graphql.operate() cache value resolved' - ) - - const fetchEventData = await fetchEvent - - t.equals( - fetchEventData.cacheKey, - cacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - - await t.resolveMatch( - fetchEventData.cacheValuePromise, - expectedResolvedCacheValue, - 'GraphQL `fetch` event data property `cacheValuePromise` resolved cache' - ) - - const cacheEventData = await cacheEvent - - t.equals( - cacheEventData.cacheKey, - cacheKey, - 'GraphQL `cache` event data property `cacheKey`' - ) - - t.deepEquals( - cacheEventData.cacheValue, - expectedResolvedCacheValue, - 'GraphQL `cache` event data property `cacheValue`' - ) - - t.type( - cacheEventData.response, - expectedResponseType, - 'GraphQL `cache` event data property `response`' - ) - - if (resetEvent) { - const resetEventData = await resetEvent - t.equals( - resetEventData.exceptCacheKey, - cacheKey, - 'GraphQL `reset` event data property `exceptCacheKey`' - ) - } - - t.deepEquals( - graphql.cache, - { - // If the cache was reset after loading, the only entry should be the - // last query. Otherwise, the new cache value should be merged into the - // initial GraphQL cache. - ...(resetOnLoad ? {} : initialGraphQLCache), - [cacheKey]: expectedResolvedCacheValue - }, - 'GraphQL cache' - ) - - if (callback) callback({ cacheKey }) - } - - await t.test('Without and with initial cache', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - const expectedResolvedCacheValue = { data: { echo: 'hello' } } - - let hash - - await t.test( - 'Without initial cache', - testQuery({ - port, - expectedResolvedCacheValue, - callback({ cacheKey }) { - hash = cacheKey - } - }) - ) - - await t.test( - 'With initial cache', - testQuery({ - port, - initialGraphQLCache: { - [hash]: expectedResolvedCacheValue - }, - expectedResolvedCacheValue - }) - ) - }) - - await t.test('With global fetch unavailable', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - - // Store the global fetch polyfill. - const { fetch } = global - - // Delete the global fetch polyfill. - delete global.fetch - - await t.test( - 'Run query', - testQuery({ - port, - expectedResolvedCacheValue: { - fetchError: 'Global fetch API or polyfill unavailable.' - }, - expectedResponseType: 'undefined' - }) - ) - - // Restore the global fetch polyfill. - global.fetch = fetch - }) - - await t.test('With HTTP and parse errors', async t => { - const port = await startServer( - t, - new Koa().use(async (ctx, next) => { - ctx.response.status = 404 - ctx.response.type = 'text/plain' - ctx.response.body = 'Not found.' - await next() - }) - ) - - await t.test( - 'Run query', - testQuery({ - port, - expectedResolvedCacheValue: { - httpError: { - status: 404, - statusText: 'Not Found' - }, - parseError: `invalid json response body at http://localhost:${port}/ reason: Unexpected token N in JSON at position 0` - } - }) - ) - }) - - await t.test('With parse error', async t => { - const port = await startServer( - t, - new Koa().use(async (ctx, next) => { - ctx.response.status = 200 - ctx.response.type = 'text' - ctx.response.body = 'Not JSON.' - await next() - }) - ) - - await t.test( - 'Run query', - testQuery({ - port, - expectedResolvedCacheValue: { - parseError: `invalid json response body at http://localhost:${port}/ reason: Unexpected token N in JSON at position 0` - } - }) - ) - }) - - await t.test('With malformed response payload', async t => { - const port = await startServer( - t, - new Koa().use(async (ctx, next) => { - ctx.response.status = 200 - ctx.response.type = 'json' - ctx.response.body = '[{"bad": true}]' - await next() - }) - ) - - await t.test( - 'Run query', - testQuery({ - port, - expectedResolvedCacheValue: { - parseError: 'Malformed payload.' - } - }) - ) - }) - - await t.test('With HTTP and GraphQL errors', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - - await t.test( - 'Run query', - testQuery({ - port, - operation: { query: '{ b }' }, - expectedResolvedCacheValue: { - httpError: { - status: 400, - statusText: 'Bad Request' - }, - graphQLErrors: [ - { - message: 'Cannot query field "b" on type "Query".', - locations: [ - { - line: 1, - column: 3 - } - ] - } - ] - } - }) - ) - }) - - await t.test('With `resetOnLoad` option', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - - const initialGraphQLCache = { - abcdefg: { - data: { - b: true - } - } - } - - const expectedResolvedCacheValue = { - data: { - echo: 'hello' - } - } - - await t.test( - '`resetOnLoad` false (default)', - testQuery({ - port, - initialGraphQLCache, - expectedResolvedCacheValue - }) - ) - - await t.test( - '`resetOnLoad` true', - testQuery({ - port, - resetOnLoad: true, - initialGraphQLCache, - expectedResolvedCacheValue - }) - ) - }) - - await t.test('With both `reloadOnLoad` and `resetOnLoad` options true', t => { - const graphql = new GraphQL() - - t.throws(() => { - graphql.operate({ - operation: { query: '' }, - reloadOnLoad: true, - resetOnLoad: true - }) - }, new Error('operate() options “reloadOnLoad” and “resetOnLoad” can’t both be true.')) - - t.end() - }) -}) - -t.test('Concurrent identical queries share a request', async t => { - let requestCount = 0 - const port = await startServer( - t, - createGraphQLKoaApp({ - requestCount: { - type: GraphQLInt, - resolve: () => ++requestCount - } - }) - ) - - const graphql = new GraphQL() - - const queryOptions = { - fetchOptionsOverride(options) { - options.url = `http://localhost:${port}` - }, - operation: { - query: '{ requestCount }' - } - } - - const { - cacheKey: cacheKey1, - cacheValuePromise: cacheValuePromise1 - } = graphql.operate(queryOptions) - const { - cacheKey: cacheKey2, - cacheValuePromise: cacheValuePromise2 - } = graphql.operate(queryOptions) - - // To be sure no mistake was made in the test. - t.equals(cacheKey1, cacheKey2, 'Shared fetch options hash') - - t.equals(cacheValuePromise1, cacheValuePromise2, 'Shared request') - - t.equals( - Object.keys(graphql.operations).length, - 1, - 'Number of GraphQL operations loading' - ) - - t.equals( - cacheKey1 in graphql.operations, - true, - 'graphql.operations contains the cache key' - ) - - await Promise.all([cacheValuePromise1, cacheValuePromise2]) -}) - -t.test('GraphQL.reload()', async t => { - await t.test('With `exceptCacheKey` parameter', async t => { - const graphql = new GraphQL() - const exceptCacheKey = 'abcdefg' - const reloadEvent = promisifyEvent(graphql, 'reload') - - graphql.reload(exceptCacheKey) - - const reloadEventData = await reloadEvent - t.equals( - reloadEventData.exceptCacheKey, - exceptCacheKey, - 'GraphQL `reload` event data property `exceptCacheKey`' - ) - }) - - await t.test('Without `exceptCacheKey` parameter', async t => { - const graphql = new GraphQL() - const reloadEvent = promisifyEvent(graphql, 'reload') - - graphql.reload() - - const reloadEventData = await reloadEvent - t.equals( - reloadEventData.exceptCacheKey, - undefined, - 'GraphQL `reload` event data property `exceptCacheKey`' - ) - }) -}) - -t.test('GraphQL.reset()', async t => { - await t.test('Without `exceptCacheKey` parameter', async t => { - const graphql = new GraphQL({ - cache: { - abcdefg: { - data: { - echo: 'hello' - } - } - } - }) - - const resetEvent = promisifyEvent(graphql, 'reset') - - graphql.reset() - - const resetEventData = await resetEvent - t.equals( - resetEventData.exceptCacheKey, - undefined, - 'GraphQL `reset` event data property `exceptCacheKey`' - ) - - t.deepEquals(graphql.cache, {}, 'GraphQL.cache') - }) - - await t.test('With `exceptCacheKey` parameter', async t => { - const cache1 = { - abcdefg: { - data: { - echo: 'hello' - } - } - } - - const cache2 = { - ghijkl: { - data: { - echo: 'hello' - } - } - } - - const graphql = new GraphQL({ - cache: { - ...cache1, - ...cache2 - } - }) - - const exceptCacheKey = 'abcdefg' - - const resetEvent = promisifyEvent(graphql, 'reset') - - graphql.reset(exceptCacheKey) - - const resetEventData = await resetEvent - t.equals( - resetEventData.exceptCacheKey, - exceptCacheKey, - 'GraphQL `reset` event data property `exceptCacheKey`' - ) - - t.deepEquals(graphql.cache, cache1, 'GraphQL.cache') - }) -}) diff --git a/src/test/graphqlFetchOptions.mjs b/src/test/graphqlFetchOptions.mjs deleted file mode 100644 index 1b8d8b2..0000000 --- a/src/test/graphqlFetchOptions.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { ReactNativeFile } from 'extract-files' -import FormData from 'formdata-node' -import t from 'tap' -import { graphqlFetchOptions } from '../universal/graphqlFetchOptions' - -// Global FormData polyfill. -global.FormData = FormData - -t.test('graphqlFetchOptions', async t => { - await t.test('Without files', t => { - t.deepEquals( - graphqlFetchOptions({ query: '' }), - { - url: '/graphql', - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: '{"query":""}' - }, - 'Fetch options' - ) - t.end() - }) - - await t.test('With files', t => { - const file = new ReactNativeFile({ - uri: '', - name: 'a.jpg', - type: 'image/jpeg' - }) - const options = graphqlFetchOptions({ - query: '', - variables: { a: file } - }) - - // See the GraphQL multipart request spec: - // https://github.com/jaydenseric/graphql-multipart-request-spec - - t.type(options.body, FormData, 'Fetch options `body` type') - t.deepEquals( - options, - { - url: '/graphql', - method: 'POST', - headers: { - Accept: 'application/json' - }, - body: [ - ['operations', '{"query":"","variables":{"a":null}}'], - ['map', '{"1":["variables.a"]}'], - ['1', '[object Object]'] - ] - }, - 'Fetch options' - ) - t.end() - }) -}) diff --git a/src/test/hashObject.mjs b/src/test/hashObject.mjs deleted file mode 100644 index efe1df8..0000000 --- a/src/test/hashObject.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import FormData from 'formdata-node' -import t from 'tap' -import { hashObject } from '../universal/hashObject' - -// Global FormData polyfill. -global.FormData = FormData - -t.test('hashObject() with an object', t => { - const object = { a: 1, b: 2 } - - const hash1 = hashObject(object) - - t.type(hash1, 'string', 'Hash type') - - const hash2 = hashObject(object) - - t.equals(hash1, hash2, 'Deterministic hash') - - object.b = 3 - - const hash3 = hashObject(object) - - t.notEquals(hash2, hash3, 'Property values affect the hash') - - t.end() -}) - -t.test('hashObject() with a FormData instance', t => { - const form1 = new FormData() - const form2 = new FormData() - - form1.append('1', 'a') - form2.append('1', 'b') - - const hash1 = hashObject(form1) - const hash2 = hashObject(form1) - const hash3 = hashObject(form2) - - t.type(hash1, 'string', 'Hash type') - t.equals(hash1, hash2, 'Deterministic hash') - t.notEquals(hash2, hash3, 'Fields determine hash') - t.end() -}) diff --git a/src/test/helpers/createGraphQLKoaApp.mjs b/src/test/helpers/createGraphQLKoaApp.mjs deleted file mode 100644 index b6af2a3..0000000 --- a/src/test/helpers/createGraphQLKoaApp.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString -} from 'graphql' -import { errorHandler, execute } from 'graphql-api-koa' -import Koa from 'koa' -import bodyParser from 'koa-bodyparser' - -/** - * Creates a GraphQL Koa app. - * @param {object} fields GraphQL `query` fields. - * @returns {object} Koa instance. - */ -export const createGraphQLKoaApp = ( - fields = { - echo: { - type: new GraphQLNonNull(GraphQLString), - args: { - phrase: { - type: GraphQLString, - defaultValue: 'hello' - } - }, - resolve: (root, { phrase }) => phrase - } - } -) => - new Koa() - .use(errorHandler()) - .use(bodyParser()) - .use( - execute({ - schema: new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields - }) - }) - }) - ) diff --git a/src/test/helpers/promisifyEvent.mjs b/src/test/helpers/promisifyEvent.mjs deleted file mode 100644 index fb4ae79..0000000 --- a/src/test/helpers/promisifyEvent.mjs +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Promisifies an event. - * @param {object} emitter An event emitter with `on` and `off` methods. - * @param {string} event The event name. - * @param {number} [timeout=1000] How many milliseconds to wait for the event. - * @returns {Promise<*>} Event data. - * @see [Stack Overflow answer](https://stackoverflow.com/a/40353376/1596978). - * @ignore - */ -export const promisifyEvent = (emitter, event, timeout = 1000) => - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - emitter.off(event, listener) - reject(new Error(`Event “${event}” wait timeout.`)) - }, timeout) - - /** - * Listener for the event. - * @param {*} data Event data. - * @ignore - */ - function listener(data) { - clearTimeout(timer) - emitter.off(event, listener) - resolve(data) - } - - emitter.on(event, listener) - }) diff --git a/src/test/helpers/sleep.mjs b/src/test/helpers/sleep.mjs deleted file mode 100644 index efcc654..0000000 --- a/src/test/helpers/sleep.mjs +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Sleeps the process for a specified duration. - * @param {number} ms Duration in milliseconds. - * @returns {Promise} Resolves once the duration is up. - */ -export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) diff --git a/src/test/helpers/startServer.mjs b/src/test/helpers/startServer.mjs deleted file mode 100644 index 53424c8..0000000 --- a/src/test/helpers/startServer.mjs +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Asynchronously starts a given Koa app server that automatically closes when - * the given test tears down. - * @param {object} t Tap test. - * @param {object} app Koa app. - * @returns {Promise} Promise resolving the Node.js net server port. - * @ignore - */ -export const startServer = (t, app) => - new Promise((resolve, reject) => { - app.listen(function(error) { - if (error) reject(error) - else { - t.tearDown(() => this.close()) - resolve(this.address().port) - } - }) - }) diff --git a/src/test/ssr.mjs b/src/test/ssr.mjs deleted file mode 100644 index 934b2af..0000000 --- a/src/test/ssr.mjs +++ /dev/null @@ -1,92 +0,0 @@ -import 'cross-fetch/polyfill' -import React from 'react' -import t from 'tap' -import { ssr } from '../server/ssr' -import { GraphQL } from '../universal/GraphQL' -import { GraphQLContext } from '../universal/GraphQLContext' -import { useGraphQL } from '../universal/useGraphQL' -import { createGraphQLKoaApp } from './helpers/createGraphQLKoaApp' -import { startServer } from './helpers/startServer' - -t.test('ssr() argument validation', async t => { - const graphql = new GraphQL() - - await t.test('Argument 1', async t => { - const error = new Error('ssr() argument 1 must be a GraphQL instance') - await t.rejects(ssr(), error, 'Rejection error if missing') - await t.rejects(ssr(true), error, 'Rejection error if wrong type') - }) - - await t.test('Argument 2', async t => { - const error = new Error('ssr() argument 2 must be a React node') - await t.rejects(ssr(graphql), error, 'Rejection error if missing') - await t.resolveMatch( - ssr(graphql, undefined), - '', - 'Resolves if undefined is passed' - ) - }) - - await t.test('Argument 3', async t => { - const error = new Error('ssr() argument 3 must be a function') - const node = 'a' - await t.resolveMatch(ssr(graphql, node), node, 'Defaults if missing') - await t.rejects( - ssr(graphql, node, false), - error, - 'Rejection error if wrong type' - ) - }) -}) - -t.test('ssr() query', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - - // eslint-disable-next-line react/prop-types - const Component = ({ phrase, children }) => { - const { loading, cacheValue } = useGraphQL({ - loadOnMount: true, - operation: { query: `{ echo(phrase: "${phrase}") }` }, - fetchOptionsOverride(options) { - options.url = `http://localhost:${port}` - } - }) - - return cacheValue && cacheValue.data ? ( - <> -

{cacheValue.data.echo}

- {children} - - ) : loading ? ( - 'Loading…' - ) : ( - 'Error!' - ) - } - - await t.test('Single query', async t => { - const graphql = new GraphQL() - const html = await ssr( - graphql, - - - - ) - - t.equals(html, '

a

', 'Rendered HTML') - }) - - await t.test('Nested query', async t => { - const graphql = new GraphQL() - const html = await ssr( - graphql, - - - - - - ) - - t.equals(html, '

a

b

', 'Rendered HTML') - }) -}) diff --git a/src/test/useGraphQL.mjs b/src/test/useGraphQL.mjs deleted file mode 100644 index 7c4f293..0000000 --- a/src/test/useGraphQL.mjs +++ /dev/null @@ -1,581 +0,0 @@ -import 'cross-fetch/polyfill' -import React from 'react' -import ReactDOMServer from 'react-dom/server' -import ReactTestRenderer from 'react-test-renderer' -import t from 'tap' -import { GraphQL } from '../universal/GraphQL' -import { GraphQLContext } from '../universal/GraphQLContext' -import { GraphQLProvider } from '../universal/GraphQLProvider' -import { useGraphQL } from '../universal/useGraphQL' -import { createGraphQLKoaApp } from './helpers/createGraphQLKoaApp' -import { promisifyEvent } from './helpers/promisifyEvent' -import { sleep } from './helpers/sleep' -import { startServer } from './helpers/startServer' - -t.test('useGraphQL()', async t => { - const port = await startServer(t, createGraphQLKoaApp()) - const graphql = new GraphQL() - - const fetchOptionsOverride = options => { - options.url = `http://localhost:${port}` - } - - const operation1Options = { - operation: { query: '{ echo }' }, - fetchOptionsOverride - } - const { - cacheKey: operation1CacheKey, - cacheValuePromise: operation1CacheValuePromise - } = graphql.operate(operation1Options) - const operation1CacheValue = await operation1CacheValuePromise - - const operation2Options = { - operation: { query: '{ echo(phrase: "goodbye") }' }, - fetchOptionsOverride - } - const { - cacheKey: operation2CacheKey, - cacheValuePromise: operation2CacheValuePromise - } = graphql.operate(operation2Options) - const operation2CacheValue = await operation2CacheValuePromise - const { cache } = graphql - - // eslint-disable-next-line react/prop-types - const Component = operationOptions => { - const result = useGraphQL(operationOptions) - return JSON.stringify(result) - } - - await t.test('Without initial cache', async t => { - await t.test('`loadOnMount` true', async t => { - const graphql = new GraphQL() - const testRenderer = ReactTestRenderer.create(null) - - await t.test('First render', t => { - let cacheKeyFetched - - graphql.on('fetch', ({ cacheKey }) => { - cacheKeyFetched = cacheKey - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, true, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.equals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals( - cacheKeyFetched, - operation1CacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - t.end() - }) - - await t.test('Second render with different props', t => { - let cacheKeyFetched - - graphql.on('fetch', ({ cacheKey }) => { - cacheKeyFetched = cacheKey - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, true, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.equals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals( - cacheKeyFetched, - operation2CacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - t.end() - }) - }) - - await t.test('`loadOnMount` false (default)', async t => { - const graphql = new GraphQL() - const testRenderer = ReactTestRenderer.create(null) - - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - await t.test('First render', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.equals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - await t.test('Second render with different props', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.equals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - }) - }) - - await t.test('With initial cache', async t => { - await t.test('`loadOnMount` true', async t => { - const graphql = new GraphQL({ cache }) - const testRenderer = ReactTestRenderer.create(null) - - await t.test('First render', t => { - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation1CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - await t.test('Second render with different props', t => { - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation2CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - // Exceed the 1000ms duration considered the first render hydration period. - await sleep(1100) - - await t.test('Third render with original props again', t => { - let cacheKeyFetched - - graphql.on('fetch', ({ cacheKey }) => { - cacheKeyFetched = cacheKey - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, true, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation1CacheValue, - 'Hook return `cacheValue`' - ) - t.equals( - cacheKeyFetched, - operation1CacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - t.end() - }) - }) - - await t.test('`loadOnMount` false (default)', async t => { - const graphql = new GraphQL({ cache }) - const testRenderer = ReactTestRenderer.create(null) - - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - await t.test('First render', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation1CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - await t.test('Second render with different props', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation2CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - }) - }) - - await t.test('With initial cache (partial)', async t => { - await t.test('`loadOnMount` true', async t => { - const graphql = new GraphQL({ - cache: { [operation1CacheKey]: operation1CacheValue } - }) - const testRenderer = ReactTestRenderer.create(null) - - await t.test('First render with cache', t => { - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation1CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - await t.test('Second render with different props, no cache', t => { - let cacheKeyFetched - - graphql.on('fetch', ({ cacheKey }) => { - cacheKeyFetched = cacheKey - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, true, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.deepEquals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals( - cacheKeyFetched, - operation2CacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - t.end() - }) - }) - - await t.test('`loadOnMount` false (default)', async t => { - const graphql = new GraphQL({ - cache: { [operation1CacheKey]: operation1CacheValue } - }) - const testRenderer = ReactTestRenderer.create(null) - - let fetched = false - - graphql.on('fetch', () => { - fetched = true - }) - - await t.test('First render', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals( - cacheValue, - operation1CacheValue, - 'Hook return `cacheValue`' - ) - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - - await t.test('Second render with different props', t => { - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse( - testRenderer.toJSON() - ) - - t.equals(loading, false, 'Hook return `loading`') - t.equals(cacheKey, operation2CacheKey, 'Hook return `cacheKey`') - t.deepEquals(cacheValue, undefined, 'Hook return `cacheValue`') - t.equals(fetched, false, 'Didn’t load') - t.end() - }) - }) - }) - - await t.test('With `reloadOnLoad` true', async t => { - const graphql = new GraphQL() - const reloadEvent = promisifyEvent(graphql, 'reload') - const testRenderer = ReactTestRenderer.create(null) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { cacheKey } = JSON.parse(testRenderer.toJSON()) - const reloadEventData = await reloadEvent - - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.equals( - reloadEventData.exceptCacheKey, - operation1CacheKey, - 'GraphQL `reload` event data property `exceptCacheKey`' - ) - }) - - await t.test('With `resetOnLoad` true', async t => { - const graphql = new GraphQL() - const resetEvent = promisifyEvent(graphql, 'reset') - const testRenderer = ReactTestRenderer.create(null) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { cacheKey } = JSON.parse(testRenderer.toJSON()) - const resetEventData = await resetEvent - - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.equals( - resetEventData.exceptCacheKey, - operation1CacheKey, - 'GraphQL `reset` event data property `exceptCacheKey`' - ) - }) - - await t.test('With both `reloadOnLoad` and `resetOnLoad` options true', t => { - const graphql = new GraphQL() - - t.throws(() => { - ReactDOMServer.renderToString( - - - - ) - }, new Error('useGraphQL() options “reloadOnLoad” and “resetOnLoad” can’t both be true.')) - - t.end() - }) - - await t.test('First render date context missing', t => { - const graphql = new GraphQL({ cache }) - const testRenderer = ReactTestRenderer.create(null) - - let cacheKeyFetched - - graphql.on('fetch', ({ cacheKey }) => { - cacheKeyFetched = cacheKey - }) - - ReactTestRenderer.act(() => { - testRenderer.update( - - - - ) - }) - - const { loading, cacheKey, cacheValue } = JSON.parse(testRenderer.toJSON()) - - t.equals(loading, true, 'Hook return `loading`') - t.equals(cacheKey, operation1CacheKey, 'Hook return `cacheKey`') - t.deepEquals(cacheValue, operation1CacheValue, 'Hook return `cacheValue`') - t.equals( - cacheKeyFetched, - operation1CacheKey, - 'GraphQL `fetch` event data property `cacheKey`' - ) - t.end() - }) - - await t.test('GraphQL context missing', t => { - t.throws(() => { - ReactDOMServer.renderToString() - }, new Error('GraphQL context missing.')) - - t.end() - }) - - await t.test('GraphQL context not a GraphQL instance', t => { - t.throws(() => { - ReactDOMServer.renderToString( - - - - ) - }, new Error('GraphQL context must be a GraphQL instance.')) - - t.end() - }) -}) diff --git a/src/universal/.babelrc.js b/src/universal/.babelrc.js deleted file mode 100644 index 6b07f07..0000000 --- a/src/universal/.babelrc.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - comments: false, - presets: [ - { plugins: ['babel-plugin-transform-replace-object-assign'] }, - [ - '@babel/env', - { - targets: 'Node >= 8.10, > 0.5%, not OperaMini all, not dead', - modules: process.env.BABEL_ESM ? false : 'commonjs', - shippedProposals: true, - loose: true - } - ], - ['@babel/react', { useBuiltIns: true }] - ], - plugins: [ - ['@babel/proposal-object-rest-spread', { loose: true, useBuiltIns: true }], - ['@babel/proposal-class-properties', { loose: true }], - '@babel/transform-runtime' - ] -} diff --git a/src/universal/FirstRenderDateContext.mjs b/src/universal/FirstRenderDateContext.mjs deleted file mode 100644 index 3eb780d..0000000 --- a/src/universal/FirstRenderDateContext.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -/** - * [React context object](https://reactjs.org/docs/context#api) for a `Date` - * instance indicating when the ancestor - * [`GraphQLProvider`]{@link GraphQLProvider} first rendered. - * @type {object} - * @prop {Function} Provider [React context provider component](https://reactjs.org/docs/context#contextprovider). - * @prop {Function} Consumer [React context consumer component](https://reactjs.org/docs/context#contextconsumer). - * @ignore - */ -export const FirstRenderDateContext = React.createContext() - -FirstRenderDateContext.displayName = 'FirstRenderDateContext' diff --git a/src/universal/GraphQL.mjs b/src/universal/GraphQL.mjs deleted file mode 100644 index b2a629b..0000000 --- a/src/universal/GraphQL.mjs +++ /dev/null @@ -1,244 +0,0 @@ -import mitt from 'mitt' -import { graphqlFetchOptions } from './graphqlFetchOptions' -import { hashObject } from './hashObject' - -/** - * A lightweight GraphQL client that caches queries and mutations. - * @kind class - * @name GraphQL - * @param {object} [options={}] Options. - * @param {GraphQLCache} [options.cache={}] Cache to import; usually from a server side render. - * @see [`reportCacheErrors`]{@link reportCacheErrors} to setup error reporting. - * @example Construct a GraphQL client. - * ```js - * import { GraphQL } from 'graphql-react' - * - * const graphql = new GraphQL() - * ``` - */ -export class GraphQL { - constructor({ cache = {} } = {}) { - const { on, off, emit } = mitt() - - /** - * Adds an event listener. - * @kind function - * @name GraphQL#on - * @param {string} type Event type. - * @param {Function} handler Event handler. - * @see [`reportCacheErrors`]{@link reportCacheErrors} can be used with this to setup error reporting. - */ - this.on = on - - /** - * Removes an event listener. - * @kind function - * @name GraphQL#off - * @param {string} type Event type. - * @param {Function} handler Event handler. - */ - this.off = off - - /** - * Emits an event with details to listeners. - * @param {string} type Event type. - * @param {*} [details] Event details. - * @ignore - */ - this.emit = emit - - /** - * Cache of loaded GraphQL operations. You probably don’t need to interact - * with this unless you’re implementing a server side rendering framework. - * @kind member - * @name GraphQL#cache - * @type {GraphQLCache} - * @example Export cache as JSON. - * ```js - * const exportedCache = JSON.stringify(graphql.cache) - * ``` - * @example Example cache JSON. - * ```json - * { - * "a1bCd2": { - * "data": { - * "viewer": { - * "name": "Jayden Seric" - * } - * } - * } - * } - * ``` - */ - this.cache = cache - - /** - * A map of loading GraphQL operations. You probably don’t need to interact - * with this unless you’re implementing a server side rendering framework. - * @kind member - * @name GraphQL#operations - * @type {object.>} - */ - this.operations = {} - } - - /** - * Signals that [GraphQL cache]{@link GraphQL#cache} subscribers such as the - * [`useGraphQL`]{@link useGraphQL} React hook should reload their GraphQL - * operation. Emits a [`GraphQL`]{@link GraphQL} instance `reload` event. - * @kind function - * @name GraphQL#reload - * @param {GraphQLCacheKey} [exceptCacheKey] A [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey} for cache to exempt from reloading. - * @example Reloading the [GraphQL cache]{@link GraphQL#cache}. - * ```js - * graphql.reload() - * ``` - */ - reload = exceptCacheKey => { - this.emit('reload', { exceptCacheKey }) - } - - /** - * Resets the [GraphQL cache]{@link GraphQL#cache}, useful when a user logs - * out. Emits a [`GraphQL`]{@link GraphQL} instance `reset` event. - * @kind function - * @name GraphQL#reset - * @param {GraphQLCacheKey} [exceptCacheKey] A [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey} for cache to exempt from deletion. Useful for resetting cache after a mutation, preserving the mutation cache. - * @example Resetting the [GraphQL cache]{@link GraphQL#cache}. - * ```js - * graphql.reset() - * ``` - */ - reset = exceptCacheKey => { - let cacheKeys = Object.keys(this.cache) - - if (exceptCacheKey) - cacheKeys = cacheKeys.filter(hash => hash !== exceptCacheKey) - - cacheKeys.forEach(cacheKey => delete this.cache[cacheKey]) - - // Emit cache updates after the entire cache has been updated, so logic in - // listeners can assume cache for all queries is fresh and stable. - this.emit('reset', { exceptCacheKey }) - } - - /** - * Fetches a GraphQL operation. - * @param {GraphQLFetchOptions} fetchOptions URL and options for [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API). - * @param {GraphQLCacheKey} cacheKey [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey}. - * @returns {Promise} A promise that resolves the [GraphQL cache]{@link GraphQL#cache} [value]{@link GraphQLCacheValue}. - * @ignore - */ - fetch = ({ url, ...options }, cacheKey) => { - let fetchResponse - - const fetcher = - typeof fetch === 'function' - ? fetch - : () => - Promise.reject( - new Error('Global fetch API or polyfill unavailable.') - ) - const cacheValue = {} - const cacheValuePromise = fetcher(url, options) - .then( - response => { - fetchResponse = response - - if (!response.ok) - cacheValue.httpError = { - status: response.status, - statusText: response.statusText - } - - return response.json().then( - ({ errors, data }) => { - // JSON parse ok. - if (!errors && !data) cacheValue.parseError = 'Malformed payload.' - if (errors) cacheValue.graphQLErrors = errors - if (data) cacheValue.data = data - }, - ({ message }) => { - // JSON parse error. - cacheValue.parseError = message - } - ) - }, - ({ message }) => { - cacheValue.fetchError = message - } - ) - .then(() => { - // Cache the operation. - this.cache[cacheKey] = cacheValue - - // Clear the loaded operation. - delete this.operations[cacheKey] - - this.emit('cache', { - cacheKey, - cacheValue, - - // May be undefined if there was a fetch error. - response: fetchResponse - }) - - return cacheValue - }) - - this.operations[cacheKey] = cacheValuePromise - - this.emit('fetch', { cacheKey, cacheValuePromise }) - - return cacheValuePromise - } - - /** - * Loads or reuses an already loading GraphQL operation in - * [GraphQL operations]{@link GraphQL#operations}. Emits a - * [`GraphQL`]{@link GraphQL} instance `fetch` event if an already loading - * operation isn’t reused, and a `cache` event once it’s loaded into the - * [GraphQL cache]{@link GraphQL#cache}. - * @kind function - * @name GraphQL#operate - * @param {object} options Options. - * @param {GraphQLOperation} options.operation GraphQL operation. - * @param {GraphQLFetchOptionsOverride} [options.fetchOptionsOverride] Overrides default GraphQL operation [`fetch` options]{@link GraphQLFetchOptions}. - * @param {boolean} [options.reloadOnLoad=false] Should a [GraphQL reload]{@link GraphQL#reload} happen after the operation loads, excluding the loaded operation cache. - * @param {boolean} [options.resetOnLoad=false] Should a [GraphQL reset]{@link GraphQL#reset} happen after the operation loads, excluding the loaded operation cache. - * @returns {GraphQLOperationLoading} Loading GraphQL operation details. - */ - operate = ({ - operation, - fetchOptionsOverride, - reloadOnLoad, - resetOnLoad - }) => { - if (reloadOnLoad && resetOnLoad) - throw new Error( - 'operate() options “reloadOnLoad” and “resetOnLoad” can’t both be true.' - ) - - const fetchOptions = graphqlFetchOptions(operation) - if (fetchOptionsOverride) fetchOptionsOverride(fetchOptions) - const cacheKey = hashObject(fetchOptions) - const cacheValuePromise = - // Use an identical existing request or… - this.operations[cacheKey] || - // …make a fresh request. - this.fetch(fetchOptions, cacheKey) - - // Potential edge-case issue: Multiple identical queries with resetOnLoad - // enabled will cause excessive resets. - cacheValuePromise.then(() => { - if (reloadOnLoad) this.reload(cacheKey) - else if (resetOnLoad) this.reset(cacheKey) - }) - - return { - cacheKey, - cacheValue: this.cache[cacheKey], - cacheValuePromise - } - } -} diff --git a/src/universal/GraphQLContext.mjs b/src/universal/GraphQLContext.mjs deleted file mode 100644 index ea770e2..0000000 --- a/src/universal/GraphQLContext.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react' - -/** - * [React context object](https://reactjs.org/docs/context#api) for a - * [`GraphQL`]{@link GraphQL} instance. - * @kind constant - * @name GraphQLContext - * @type {object} - * @prop {Function} Provider [React context provider component](https://reactjs.org/docs/context#contextprovider). - * @prop {Function} Consumer [React context consumer component](https://reactjs.org/docs/context#contextconsumer). - * @see [`GraphQLProvider`]{@link GraphQLProvider} is used to provide this context. - * @see [`useGraphQL`]{@link useGraphQL} React hook requires an ancestor [`GraphQLContext`]{@link GraphQLContext} `Provider` to work. - * @example A button component that resets the [GraphQL cache]{@link GraphQL#cache}. - * ```jsx - * import React from 'react' - * import { GraphQLContext } from 'graphql-react' - * - * const ResetCacheButton = () => { - * const graphql = React.useContext(GraphQLContext) - * return - * } - * ``` - */ -export const GraphQLContext = React.createContext() - -GraphQLContext.displayName = 'GraphQLContext' diff --git a/src/universal/GraphQLProvider.mjs b/src/universal/GraphQLProvider.mjs deleted file mode 100644 index c1a299d..0000000 --- a/src/universal/GraphQLProvider.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { FirstRenderDateContext } from './FirstRenderDateContext' -import { GraphQL } from './GraphQL' -import { GraphQLContext } from './GraphQLContext' - -/** - * A React component that provides a [`GraphQL`]{@link GraphQL} instance for an - * app. - * @kind function - * @name GraphQLProvider - * @param {object} props Component props. - * @param {GraphQL} props.graphql [`GraphQL`]{@link GraphQL} instance. - * @param {ReactNode} [props.children] React children. - * @returns {ReactNode} React virtual DOM node. - * @see [`GraphQLContext`]{@link GraphQLContext} is provided via this component. - * @see [`useGraphQL`]{@link useGraphQL} React hook requires this component to be an ancestor to work. - * @example Provide a [`GraphQL`]{@link GraphQL} instance for an app. - * ```jsx - * import { GraphQL, GraphQLProvider } from 'graphql-react' - * - * const graphql = new GraphQL() - * - * const App = ({ children }) => ( - * {children} - * ) - * ``` - */ -export const GraphQLProvider = ({ graphql, children }) => { - const firstRenderDateRef = React.useRef(new Date()) - - return ( - - - {children} - - - ) -} - -GraphQLProvider.propTypes = { - graphql: PropTypes.instanceOf(GraphQL).isRequired, - children: PropTypes.node -} diff --git a/src/universal/graphqlFetchOptions.mjs b/src/universal/graphqlFetchOptions.mjs deleted file mode 100644 index eab0cd5..0000000 --- a/src/universal/graphqlFetchOptions.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { extractFiles } from 'extract-files' - -/** - * Gets default [`fetch` options]{@link GraphQLFetchOptions} for a - * [GraphQL operation]{@link GraphQLOperation}. - * @param {GraphQLOperation} operation GraphQL operation. - * @returns {GraphQLFetchOptions} [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options. - * @ignore - */ -export function graphqlFetchOptions(operation) { - const fetchOptions = { - url: '/graphql', - method: 'POST', - headers: { Accept: 'application/json' } - } - - const { clone, files } = extractFiles(operation) - const operationJSON = JSON.stringify(clone) - - if (files.size) { - // See the GraphQL multipart request spec: - // https://github.com/jaydenseric/graphql-multipart-request-spec - - const form = new FormData() - - form.append('operations', operationJSON) - - const map = {} - let i = 0 - files.forEach(paths => { - map[++i] = paths - }) - form.append('map', JSON.stringify(map)) - - i = 0 - files.forEach((paths, file) => { - form.append(`${++i}`, file, file.name) - }) - - fetchOptions.body = form - } else { - fetchOptions.headers['Content-Type'] = 'application/json' - fetchOptions.body = operationJSON - } - - return fetchOptions -} diff --git a/src/universal/hashObject.mjs b/src/universal/hashObject.mjs deleted file mode 100644 index 8c7e80b..0000000 --- a/src/universal/hashObject.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import fnv1a from 'fnv1a' - -/** - * `JSON.stringify()` replacer that converts - * [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) instances - * into a signature string. - * @param {string} key Property name. - * @param {*} value Property value. - * @returns {*} Original value or replaced value if it was a `FormData` instance. - * @ignore - */ -function hashObjectReplacer(key, value) { - // Retrieve the original value, and not the possible .toJSON() version. When a - // value has a .toJSON() method, JSON.stringify provides the replacer - // function with output of that instead of the original value. FormData - // instances in browsers do not have a .toJSON() method, but some polyfill - // implementations might. - // See: https://github.com/octet-stream/form-data/issues/2 - const originalValue = this[key] - - if (typeof FormData !== 'undefined' && originalValue instanceof FormData) { - // Value is a FormData instance. The idea is to return a string representing - // the unique signature of the form, to be hashed with the surrounding JSON - // string. Note that FormData forms can have multiple fields with the same - // name and that the order of form fields also determines the signature. - - let signature = '' - - const fields = originalValue.entries() - - // Iterate manually using next() to avoid bulky for … of syntax - // transpilation. - let field = fields.next() - while (!field.done) { - const [name, value] = field.value - - // If the value is a File or Blob instance, it should cast to a string - // like `[object File]`. It would be good if there was a way to signature - // File or Blob instances. - signature += `${name}${value}` - - field = fields.next() - } - - return signature - } - - // Let JSON.stringify() stringify the value as normal. - return value -} - -/** - * Hashes an object. - * @param {object} object A JSON serializable object that may contain [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) instances. - * @returns {string} A hash. - * @ignore - */ -export const hashObject = object => - fnv1a(JSON.stringify(object, hashObjectReplacer)).toString(36) diff --git a/src/universal/index.mjs b/src/universal/index.mjs deleted file mode 100644 index 8d54864..0000000 --- a/src/universal/index.mjs +++ /dev/null @@ -1,119 +0,0 @@ -export { GraphQL } from './GraphQL' -export { GraphQLContext } from './GraphQLContext' -export { GraphQLProvider } from './GraphQLProvider' -export { useGraphQL } from './useGraphQL' -export { reportCacheErrors } from './reportCacheErrors' - -/** - * A [GraphQL cache]{@link GraphQL#cache} map of GraphQL operation results. - * @kind typedef - * @name GraphQLCache - * @type {object.} - * @see [`GraphQL`]{@link GraphQL} constructor accepts this type in `options.cache`. - * @see [`GraphQL` instance property `cache`]{@link GraphQL#cache} is this type. - */ - -/** - * A [GraphQL cache]{@link GraphQLCache} key, derived from a hash of the - * [`fetch` options]{@link GraphQLFetchOptions} of the GraphQL operation that populated - * the [value]{@link GraphQLCacheValue}. - * @kind typedef - * @name GraphQLCacheKey - * @type {string} - */ - -/** - * JSON serializable GraphQL operation result that includes errors and data. - * @kind typedef - * @name GraphQLCacheValue - * @type {object} - * @prop {string} [fetchError] `fetch` error message. - * @prop {HttpError} [httpError] `fetch` response HTTP error. - * @prop {string} [parseError] Parse error message. - * @prop {Array} [graphQLErrors] GraphQL response errors. - * @prop {object} [data] GraphQL response data. - */ - -/** - * GraphQL API URL and - * [polyfillable `fetch` options](https://github.github.io/fetch/#options). The - * `url` property gets extracted and the rest are used as - * [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) options. - * @kind typedef - * @name GraphQLFetchOptions - * @type {object} - * @prop {string} url GraphQL API URL. - * @prop {string|FormData} body HTTP request body. - * @prop {object} headers HTTP request headers. - * @prop {string} [credentials] Authentication credentials mode. - * @see [`GraphQLFetchOptionsOverride` functions]{@link GraphQLFetchOptionsOverride} accept this type. - */ - -/** - * Overrides default [GraphQL `fetch` options]{@link GraphQLFetchOptions}. - * Mutate the provided options object; there is no need to return it. - * @kind typedef - * @name GraphQLFetchOptionsOverride - * @type {Function} - * @param {GraphQLFetchOptions} options [GraphQL `fetch` options]{@link GraphQLFetchOptions} tailored to the [GraphQL operation]{@link GraphQLOperation}, e.g. if there are files to upload `options.body` will be a [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) instance conforming to the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec). - * @see [`GraphQL` instance method `operate`]{@link GraphQL#operate} accepts this type in `options.fetchOptionsOverride`. - * @see [`useGraphQL`]{@link useGraphQL} React hook accepts this type in `options.fetchOptionsOverride`. - * @example Setting [GraphQL `fetch` options]{@link GraphQLFetchOptions} for an imaginary API. - * ```js - * options => { - * options.url = 'https://api.example.com/graphql' - * options.credentials = 'include' - * } - * ``` - */ - -/** - * A GraphQL operation. Additional properties may be used; all are sent to the - * GraphQL server. - * @kind typedef - * @name GraphQLOperation - * @type {object} - * @prop {string} query GraphQL queries/mutations. - * @prop {object} variables Variables used in the `query`. - * @see [`GraphQL` instance method `operate`]{@link GraphQL#operate} accepts this type in `options.operation`. - * @see [`useGraphQL`]{@link useGraphQL} React hook accepts this type in `options.operation`. - */ - -/** - * A loading GraphQL operation. - * @kind typedef - * @name GraphQLOperationLoading - * @type {object} - * @prop {GraphQLCacheKey} cacheKey [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey}. - * @prop {GraphQLCacheValue} [cacheValue] [GraphQL cache]{@link GraphQLCache} [value]{@link GraphQLCacheValue} from the last identical query. - * @prop {Promise} cacheValuePromise Resolves the loaded [GraphQL cache]{@link GraphQLCache} [value]{@link GraphQLCacheValue}. - * @see [`GraphQL` instance method `operate`]{@link GraphQL#operate} returns this type. - */ - -/** - * The status of a GraphQL operation. - * @kind typedef - * @name GraphQLOperationStatus - * @type {object} - * @prop {Function} load Loads the GraphQL operation on demand, updating the [GraphQL cache]{@link GraphQL#cache}. - * @prop {boolean} loading Is the GraphQL operation loading. - * @prop {GraphQLCacheKey} cacheKey [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey}. - * @prop {GraphQLCacheValue} cacheValue [GraphQL cache]{@link GraphQLCache} [value]{@link GraphQLCacheValue}. - * @see [`useGraphQL`]{@link useGraphQL} React hook returns this type. - */ - -/** - * [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API) HTTP error. - * @kind typedef - * @name HttpError - * @type {object} - * @prop {number} status HTTP status code. - * @prop {string} statusText HTTP status text. - */ - -/** - * A React virtual DOM node; anything that can be rendered. - * @kind typedef - * @name ReactNode - * @type {undefined|null|boolean|number|string|React.Element|Array} - */ diff --git a/src/universal/reportCacheErrors.mjs b/src/universal/reportCacheErrors.mjs deleted file mode 100644 index eb21406..0000000 --- a/src/universal/reportCacheErrors.mjs +++ /dev/null @@ -1,69 +0,0 @@ -/** - * A [`GraphQL`]{@link GraphQL} `cache` event handler that reports - * [`fetch`](https://developer.mozilla.org/docs/Web/API/Fetch_API), HTTP, parse - * and GraphQL errors via `console.log()`. In a browser environment the grouped - * error details are expandable. - * @kind function - * @name reportCacheErrors - * @param {object} data [`GraphQL`]{@link GraphQL} `cache` event data. - * @param {GraphQLCacheKey} data.cacheKey [GraphQL cache]{@link GraphQL#cache} [key]{@link GraphQLCacheKey}. - * @param {GraphQLCacheKey} data.cacheValue [GraphQL cache]{@link GraphQL#cache} [value]{@link GraphQLCacheValue}. - * @example [`GraphQL`]{@link GraphQL} initialized to report cache errors. - * ```js - * import { GraphQL, reportCacheErrors } from 'graphql-react' - * - * const graphql = new GraphQL() - * graphql.on('cache', reportCacheErrors) - * ``` - */ -export function reportCacheErrors({ - cacheKey, - cacheValue: { fetchError, httpError, parseError, graphQLErrors } -}) { - if (fetchError || httpError || parseError || graphQLErrors) { - console.groupCollapsed(`GraphQL cache errors for key “${cacheKey}”:`) - - if (fetchError) { - console.groupCollapsed('Fetch:') - - // eslint-disable-next-line no-console - console.log(fetchError) - - console.groupEnd() - } - - if (httpError) { - console.groupCollapsed('HTTP:') - - // eslint-disable-next-line no-console - console.log(`Status: ${httpError.status}`) - - // eslint-disable-next-line no-console - console.log(`Text: ${httpError.statusText}`) - - console.groupEnd() - } - - if (parseError) { - console.groupCollapsed('Parse:') - - // eslint-disable-next-line no-console - console.log(parseError) - - console.groupEnd() - } - - if (graphQLErrors) { - console.groupCollapsed('GraphQL:') - - graphQLErrors.forEach(({ message }) => - // eslint-disable-next-line no-console - console.log(message) - ) - - console.groupEnd() - } - - console.groupEnd() - } -} diff --git a/src/universal/useGraphQL.mjs b/src/universal/useGraphQL.mjs deleted file mode 100644 index eb32dfa..0000000 --- a/src/universal/useGraphQL.mjs +++ /dev/null @@ -1,224 +0,0 @@ -import React from 'react' -import { FirstRenderDateContext } from './FirstRenderDateContext' -import { GraphQL } from './GraphQL' -import { GraphQLContext } from './GraphQLContext' -import { graphqlFetchOptions } from './graphqlFetchOptions' -import { hashObject } from './hashObject' - -/** - * A [React hook](https://reactjs.org/docs/hooks-intro) to manage a GraphQL - * operation in a component. - * @kind function - * @name useGraphQL - * @param {object} options Options. - * @param {GraphQLFetchOptionsOverride} [options.fetchOptionsOverride] Overrides default [`fetch` options]{@link GraphQLFetchOptions} for the GraphQL operation. - * @param {boolean} [options.loadOnMount=false] Should the operation load when the component mounts. - * @param {boolean} [options.loadOnReload=false] Should the operation load when the [`GraphQL`]{@link GraphQL} `reload` event fires and there is a [GraphQL cache]{@link GraphQL#cache} [value]{@link GraphQLCacheValue} to reload, but only if the operation was not the one that caused the reload. - * @param {boolean} [options.loadOnReset=false] Should the operation load when the [`GraphQL`]{@link GraphQL} `reset` event fires and the [GraphQL cache]{@link GraphQL#cache} [value]{@link GraphQLCacheValue} is deleted, but only if the operation was not the one that caused the reset. - * @param {boolean} [options.reloadOnLoad=false] Should a [GraphQL reload]{@link GraphQL#reload} happen after the operation loads, excluding the loaded operation cache. - * @param {boolean} [options.resetOnLoad=false] Should a [GraphQL reset]{@link GraphQL#reset} happen after the operation loads, excluding the loaded operation cache. - * @param {GraphQLOperation} options.operation GraphQL operation. - * @returns {GraphQLOperationStatus} GraphQL operation status. - * @see [`GraphQLContext`]{@link GraphQLContext} is required for this hook to work. - * @example A component that displays a Pokémon image. - * ```jsx - * import { useGraphQL } from 'graphql-react' - * - * const PokemonImage = ({ name }) => { - * const { loading, cacheValue = {} } = useGraphQL({ - * fetchOptionsOverride(options) { - * options.url = 'https://graphql-pokemon.now.sh' - * }, - * operation: { - * query: `{ pokemon(name: "${name}") { image } }` - * }, - * loadOnMount: true, - * loadOnReload: true, - * loadOnReset: true - * }) - * - * return cacheValue.data ? ( - * {name} - * ) : loading ? ( - * 'Loading…' - * ) : ( - * 'Error!' - * ) - *} - * ``` - * @example Options guide for common situations. - * | Situation | `loadOnMount` | `loadOnReload` | `loadOnReset` | `reloadOnLoad` | `resetOnLoad` | - * | :-- | :-: | :-: | :-: | :-: | :-: | - * | Profile query | ✔️ | ✔️ | ✔️ | | | - * | Login mutation | | | | | ✔️ | - * | Logout mutation | | | | | ✔️ | - * | Change password mutation | | | | | | - * | Change name mutation | | | | ✔️ | | - * | Like a post mutation | | | | ✔️ | | - */ -export const useGraphQL = ({ - fetchOptionsOverride, - loadOnMount, - loadOnReload, - loadOnReset, - reloadOnLoad, - resetOnLoad, - operation -}) => { - if (reloadOnLoad && resetOnLoad) - throw new Error( - 'useGraphQL() options “reloadOnLoad” and “resetOnLoad” can’t both be true.' - ) - - const graphql = React.useContext(GraphQLContext) - - if (typeof graphql === 'undefined') - throw new Error('GraphQL context missing.') - - if (!(graphql instanceof GraphQL)) - throw new Error('GraphQL context must be a GraphQL instance.') - - const fetchOptions = graphqlFetchOptions(operation) - if (fetchOptionsOverride) fetchOptionsOverride(fetchOptions) - - const fetchOptionsHash = hashObject(fetchOptions) - - let [cacheKey, setCacheKey] = React.useState(fetchOptionsHash) - let [cacheValue, setCacheValue] = React.useState(graphql.cache[cacheKey]) - let [loading, setLoading] = React.useState(cacheKey in graphql.operations) - - // If the GraphQL operation or its fetch options change after the initial - // render the state has to be re-initialized. - if (cacheKey !== fetchOptionsHash) { - setCacheKey((cacheKey = fetchOptionsHash)) - setCacheValue((cacheValue = graphql.cache[cacheKey])) - setLoading((loading = cacheKey in graphql.operations)) - } - - /** - * Loads the GraphQL query, updating state. - * @returns {Promise} Resolves the loaded [GraphQL cache]{@link GraphQLCache} [value]{@link GraphQLCacheValue}. - * @ignore - */ - const load = React.useCallback(() => { - const { cacheKey, cacheValue, cacheValuePromise } = graphql.operate({ - operation, - fetchOptionsOverride, - reloadOnLoad, - resetOnLoad - }) - - setLoading(true) - setCacheKey(cacheKey) - setCacheValue(cacheValue) - - return cacheValuePromise - }, [fetchOptionsOverride, graphql, operation, reloadOnLoad, resetOnLoad]) - - const isMountedRef = React.useRef(false) - - React.useEffect(() => { - isMountedRef.current = true - - /** - * Handles a [`GraphQL`]{@link GraphQL} `fetch` event. - * @param {object} event Event data. - * @ignore - */ - function onFetch({ cacheKey: fetchingCacheKey }) { - if (cacheKey === fetchingCacheKey && isMountedRef.current) - setLoading(true) - } - - /** - * Handles a [`GraphQL`]{@link GraphQL} `cache` event. - * @param {object} event Event data. - * @ignore - */ - function onCache({ cacheKey: cachedCacheKey, cacheValue }) { - if (cacheKey === cachedCacheKey && isMountedRef.current) { - setLoading(false) - setCacheValue(cacheValue) - } - } - - /** - * Handles a [`GraphQL`]{@link GraphQL} `reload` event. - * @param {object} event Event data. - * @ignore - */ - function onReload({ exceptCacheKey }) { - if ( - cacheKey !== exceptCacheKey && - loadOnReload && - cacheValue && - isMountedRef.current - ) - load() - } - - /** - * Handles a [`GraphQL`]{@link GraphQL} `reset` event. - * @param {object} event Event data. - * @ignore - */ - function onReset({ exceptCacheKey }) { - if (cacheKey !== exceptCacheKey && isMountedRef.current) - if (loadOnReset) load() - else setCacheValue(graphql.cache[cacheKey]) - } - - graphql.on('fetch', onFetch) - graphql.on('cache', onCache) - graphql.on('reload', onReload) - graphql.on('reset', onReset) - - return () => { - isMountedRef.current = false - - graphql.off('fetch', onFetch) - graphql.off('cache', onCache) - graphql.off('reload', onReload) - graphql.off('reset', onReset) - } - }, [cacheKey, cacheValue, graphql, load, loadOnReload, loadOnReset]) - - const [loadedOnMountCacheKey, setLoadedOnMountCacheKey] = React.useState() - - // Note: Allowed to be undefined for apps that don’t provide this context. - const firstRenderDate = React.useContext(FirstRenderDateContext) - - React.useEffect(() => { - if ( - loadOnMount && - // The load on mount hasn’t been triggered yet. - cacheKey !== loadedOnMountCacheKey && - !( - cacheValue && - // Within a short enough time since the GraphQL provider first rendered - // to be considered post SSR hydration. - new Date() - firstRenderDate < 1000 - ) - ) { - setLoadedOnMountCacheKey(cacheKey) - load() - } - }, [ - cacheKey, - cacheValue, - firstRenderDate, - load, - loadOnMount, - loadedOnMountCacheKey - ]) - - if (graphql.ssr && loadOnMount && !cacheValue) - graphql.operate({ - operation, - fetchOptionsOverride, - reloadOnLoad, - resetOnLoad - }) - - return { load, loading, cacheKey, cacheValue } -} diff --git a/test/Deferred.mjs b/test/Deferred.mjs new file mode 100644 index 0000000..4ddee6d --- /dev/null +++ b/test/Deferred.mjs @@ -0,0 +1,20 @@ +// @ts-check + +/** + * Deferred promise that can be externally resolved or rejected. + * @template [Resolves=void] What the promise resolves. + */ +export default class Deferred { + constructor() { + /** The promise. */ + this.promise = /** @type {Promise} */ ( + new Promise((resolve, reject) => { + /** Resolves the promise. */ + this.resolve = resolve; + + /** Rejects the promise. */ + this.reject = reject; + }) + ); + } +} diff --git a/test/ReactHookTest.mjs b/test/ReactHookTest.mjs new file mode 100644 index 0000000..373e9ed --- /dev/null +++ b/test/ReactHookTest.mjs @@ -0,0 +1,56 @@ +// @ts-check + +import useForceUpdate from "../useForceUpdate.mjs"; + +/** + * React component for testing a React hook. + * @template {() => HookReturnType} Hook React hook type. + * @template HookReturnType React hook return type. + * @param {object} props Props. + * @param {Hook} props.useHook React hook. + * @param {Array>} props.results React hook + * render results. + */ +export default function ReactHookTest({ useHook, results }) { + const rerender = useForceUpdate(); + + /** @type {ReactHookResult} */ + let result; + + try { + const returned = useHook(); + + result = { rerender, returned }; + } catch (threw) { + result = { rerender, threw }; + } + + results.push(result); + + return null; +} + +/** + * React hook render result. + * @template [HookReturnType=unknown] + * @typedef {Readonly< + * ReactHookResultReturned + * > | Readonly< + * ReactHookResultThrew + * >} ReactHookResult + */ + +/** + * Result if the React hook returned. + * @template [HookReturnType=unknown] + * @typedef {object} ReactHookResultReturned + * @prop {() => void} rerender Forces the component to re-render. + * @prop {HookReturnType} returned What the hook returned. + */ + +/** + * Result if the React hook threw. + * @typedef {object} ReactHookResultThrew + * @prop {() => void} rerender Forces the component to re-render. + * @prop {unknown} threw What the hook threw. + */ diff --git a/test/assertBundleSize.mjs b/test/assertBundleSize.mjs new file mode 100644 index 0000000..79148fc --- /dev/null +++ b/test/assertBundleSize.mjs @@ -0,0 +1,56 @@ +// @ts-check + +import { fail } from "node:assert"; +import { fileURLToPath } from "node:url"; + +import esbuild from "esbuild"; +import { gzipSize } from "gzip-size"; + +/** + * Asserts the minified and gzipped bundle size of a module. + * @param {URL} moduleUrl Module URL. + * @param {number} limit Minified and gzipped bundle size limit (bytes). + * @returns {Promise<{ bundle: string, gzippedSize: number }>} Resolves the + * minified bundle and its gzipped size (bytes). + */ +export default async function assertBundleSize(moduleUrl, limit) { + if (!(moduleUrl instanceof URL)) + throw new TypeError("Argument 1 `moduleUrl` must be a `URL` instance."); + + if (typeof limit !== "number") + throw new TypeError("Argument 2 `limit` must be a number."); + + const { + outputFiles: [bundle], + } = await esbuild.build({ + entryPoints: [fileURLToPath(moduleUrl)], + external: + // Package peer dependencies. + ["react"], + write: false, + bundle: true, + minify: true, + legalComments: "none", + format: "esm", + }); + + const gzippedSize = await gzipSize(bundle.text); + + if (gzippedSize > limit) + fail( + `${gzippedSize} B minified and gzipped bundle exceeds the ${limit} B limit by ${ + gzippedSize - limit + } B; increase the limit or reduce the bundle size.`, + ); + + const surplus = limit - gzippedSize; + + // Error if the surplus is greater than 25% of the limit. + if (surplus > limit * 0.25) + throw new Error( + `${gzippedSize} B minified and gzipped bundle is under the ${limit} B limit by ${surplus} B; reduce the limit.`, + ); + + // For debugging in tests. + return { bundle: bundle.text, gzippedSize }; +} diff --git a/test/assertInstanceOf.mjs b/test/assertInstanceOf.mjs new file mode 100644 index 0000000..5cf6b78 --- /dev/null +++ b/test/assertInstanceOf.mjs @@ -0,0 +1,22 @@ +// @ts-check + +import { AssertionError } from "node:assert"; +import { inspect } from "node:util"; + +/** + * Asserts a value is an instance of a given class. + * @template ExpectedClass + * @param {unknown} value Value. + * @param {{ new(...args: any): ExpectedClass }} expectedClass Expected class. + * @returns {asserts value is ExpectedClass} `void` for JavaScript and the + * assertion for TypeScript. + */ +export default function assertInstanceOf(value, expectedClass) { + if (!(value instanceof expectedClass)) + throw new AssertionError({ + message: `Expected instance of ${inspect( + expectedClass, + )} for value:\n\n${inspect(value)}\n`, + stackStartFn: assertInstanceOf, + }); +} diff --git a/test/assertTypeOf.mjs b/test/assertTypeOf.mjs new file mode 100644 index 0000000..75b973d --- /dev/null +++ b/test/assertTypeOf.mjs @@ -0,0 +1,38 @@ +// @ts-check + +import { AssertionError } from "node:assert"; +import { inspect } from "node:util"; + +/** + * Asserts a value is a given type. + * @template {keyof TypeMap} ExpectedType + * @param {unknown} value Value. + * @param {ExpectedType} expectedType Expected type. + * @returns {asserts value is TypeMap[ExpectedType]} `void` for JavaScript and + * the assertion for TypeScript. + */ +export default function assertTypeOf(value, expectedType) { + if (typeof value !== expectedType) + throw new AssertionError({ + message: `Expected type ${inspect( + expectedType, + )} but actual type is ${inspect(typeof value)} for value:\n\n${inspect( + value, + )}\n`, + stackStartFn: assertTypeOf, + }); +} + +/** + * JavaScript type to TypeScript type map. + * @typedef {{ + * bigint: BigInt, + * boolean: boolean, + * function: Function, + * number: number, + * object: object, + * string: string, + * symbol: Symbol, + * undefined: undefined + * }} TypeMap + */ diff --git a/test/createReactTestRenderer.mjs b/test/createReactTestRenderer.mjs new file mode 100644 index 0000000..306dd63 --- /dev/null +++ b/test/createReactTestRenderer.mjs @@ -0,0 +1,26 @@ +// @ts-check + +/** + * @import { ReactNode } from "react" + * @import { ReactTestRenderer as TestRenderer } from "react-test-renderer" + */ + +import ReactTestRenderer from "react-test-renderer"; + +/** + * Creates a React test renderer. + * @param {ReactNode} reactRoot Root React node to render. + */ +export default function createReactTestRenderer(reactRoot) { + /** @type {TestRenderer | undefined} */ + let testRenderer; + + ReactTestRenderer.act(() => { + testRenderer = ReactTestRenderer.create( + // @ts-ignore The React types are incorrect. + reactRoot, + ); + }); + + return /** @type {TestRenderer} */ (testRenderer); +} diff --git a/test/polyfillCustomEvent.mjs b/test/polyfillCustomEvent.mjs new file mode 100644 index 0000000..c0666b0 --- /dev/null +++ b/test/polyfillCustomEvent.mjs @@ -0,0 +1,27 @@ +// @ts-check + +// TODO: Delete this polyfill once all supported Node.js versions have the +// global `CustomEvent`: +// https://nodejs.org/api/globals.html#customevent +globalThis.CustomEvent ??= + /** + * `CustomEvent` polyfill. + * @template [T=unknown] + * @type {globalThis.CustomEvent} + */ + class CustomEvent extends Event { + /** + * @param {string} type Event type. + * @param {CustomEventInit} [options] Custom event options. + */ + constructor(type, options = {}) { + // Workaround a TypeScript bug: + // https://github.com/microsoft/TypeScript/issues/50286 + const { detail, ...eventOptions } = options; + super(type, eventOptions); + if (detail) this.detail = detail; + } + + /** @deprecated */ + initCustomEvent() {} + }; diff --git a/test/polyfillFile.mjs b/test/polyfillFile.mjs new file mode 100644 index 0000000..4b28239 --- /dev/null +++ b/test/polyfillFile.mjs @@ -0,0 +1,9 @@ +// @ts-check + +import { File as NodeFile } from "node:buffer"; + +// TODO: Delete this polyfill once all supported Node.js versions have the +// global `File`: +// https://nodejs.org/api/globals.html#class-file +// @ts-expect-error It’s not a perfect polyfill, but works for the tests. +globalThis.File ??= NodeFile; diff --git a/test/suppressReactRenderErrorConsoleOutput.mjs b/test/suppressReactRenderErrorConsoleOutput.mjs new file mode 100644 index 0000000..6076366 --- /dev/null +++ b/test/suppressReactRenderErrorConsoleOutput.mjs @@ -0,0 +1,15 @@ +// @ts-check + +import filterConsole from "filter-console"; + +/** + * Replaces the `console` global to suppress React render error output. Useful + * for testing specific render errors. + * @returns {() => void} Reverts the `console` global. + * @see [Similar utility in `@testing-library/react-hooks`](https://github.com/testing-library/react-hooks-testing-library/blob/5bae466de7155d9654194d81b5c0c3c2291b9a15/src/core/console.ts#L1-L17). + */ +export default function suppressReactRenderErrorConsoleOutput() { + return filterConsole([/^The above error occurred in the <\w+> component:/u], { + methods: ["error"], + }); +} diff --git a/types.mjs b/types.mjs new file mode 100644 index 0000000..4ff590e --- /dev/null +++ b/types.mjs @@ -0,0 +1,135 @@ +// @ts-check + +/** + * @import { CacheKey } from "./Cache.mjs" + * @import LoadingCacheValue from "./LoadingCacheValue.mjs" + */ + +// Prevent a TypeScript error when importing this module in a JSDoc type. +export {}; + +// This module contains types that aren’t specific to a single module. + +/** + * Matches a {@link CacheKey cache key} against a custom condition. + * @callback CacheKeyMatcher + * @param {CacheKey} cacheKey Cache key. + * @returns {boolean} Does the `cacheKey` match the custom condition. + */ + +/** + * GraphQL operation. Additional properties may be used; all are sent to the + * GraphQL server. + * @typedef {object} GraphQLOperation + * @prop {string} query GraphQL queries or mutations. + * @prop {{ [variableName: string ]: unknown}} [variables] Variables used in the + * GraphQL queries or mutations. + */ + +/** + * GraphQL result. + * @see [GraphQL spec for a response](https://spec.graphql.org/October2021/#sec-Response). + * @template [ErrorTypes=GraphQLResultError] Possible error types. + * @typedef {object} GraphQLResult + * @prop {Response} [response] The GraphQL server response. Non-enumerable to + * prevent it from serializing to JSON when sending SSR cache to the client + * for hydration. + * @prop {Array} [errors] GraphQL errors from the server, along with + * any loading errors added on the client. + * @prop {{ [key: string]: unknown } | null} [data] GraphQL data. + */ + +/** + * {@link GraphQLResult.errors GraphQL result error}. + * @see [GraphQL spec for response errors](https://spec.graphql.org/October2021/#sec-Errors). + * @template {{ + * [key: string]: unknown + * }} [Extensions={ [key: string]: unknown }] Extensions to a standard GraphQL + * error. + * @typedef {object} GraphQLResultError + * @prop {string} message Error message. + * @prop {Array<{ line: number; column: number }>} [locations] GraphQL query + * locations related to the error. + * @prop {Array} [path] GraphQL result + * {@link GraphQLResult.data `data`} property path related to the error. + * @prop {Extensions} [extensions] Extensions to a standard GraphQL error. + */ + +/** + * {@link GraphQLResult GraphQL result} loading error generated on the client, + * not the GraphQL server. + * @template {string} Code Error code. + * @template {{ [key: string]: unknown }} [Extensions={}] Error specific + * details. + * @typedef {object} GraphQLResultErrorLoading + * @prop {string} message Error message. + * @prop {GraphQLResultErrorLoadingMeta & Extensions} extensions Error + * specific details. + */ + +/** + * @template {string} Code Error code. + * @typedef {object} GraphQLResultErrorLoadingMeta + * @prop {true} client Error was generated on the client, not the GraphQL + * server. + * @prop {Code} code Error code. + */ + +/** + * {@link GraphQLResultError GraphQL error} that the GraphQL request had a fetch + * error, e.g. the `fetch` global isn’t defined, or the network is offline. + * @typedef {GraphQLResultErrorLoading< + * "FETCH_ERROR", + * GraphQLResultErrorLoadingFetchDetails + * >} GraphQLResultErrorLoadingFetch + */ + +/** + * @typedef {object} GraphQLResultErrorLoadingFetchDetails + * @prop {string} fetchErrorMessage Fetch error message. + */ + +/** + * {@link GraphQLResultError GraphQL error} that the GraphQL response had an + * error HTTP status. + * @typedef {GraphQLResultErrorLoading< + * "RESPONSE_HTTP_STATUS", + * GraphQLResultErrorResponseHttpStatusDetails + * >} GraphQLResultErrorResponseHttpStatus + */ + +/** + * @typedef {object} GraphQLResultErrorResponseHttpStatusDetails + * @prop {number} statusCode HTTP status code in the error range. + * @prop {string} statusText HTTP status text. + */ + +/** + * {@link GraphQLResultError GraphQL error} that the GraphQL response JSON had a + * parse error. + * @typedef {GraphQLResultErrorLoading< + * "RESPONSE_JSON_PARSE_ERROR", + * GraphQLResultErrorResponseJsonParseDetails + * >} GraphQLResultErrorResponseJsonParse + */ + +/** + * @typedef {object} GraphQLResultErrorResponseJsonParseDetails + * @prop {string} jsonParseErrorMessage JSON parse error message. + */ + +/** + * {@link GraphQLResultError GraphQL error} that the GraphQL response JSON was + * malformed because it wasn’t an object, was missing an `errors` or `data` + * property, the `errors` property wasn’t an array, or the `data` property + * wasn’t an object or `null`. + * @typedef {GraphQLResultErrorLoading< + * "RESPONSE_MALFORMED" + * >} GraphQLResultErrorResponseMalformed + */ + +/** + * Starts {@link LoadingCacheValue loading a cache value}. + * @callback Loader + * @returns {LoadingCacheValue} The loading cache value. + */ diff --git a/useAutoAbortLoad.mjs b/useAutoAbortLoad.mjs new file mode 100644 index 0000000..459e498 --- /dev/null +++ b/useAutoAbortLoad.mjs @@ -0,0 +1,57 @@ +// @ts-check + +/** + * @import LoadingCacheValue from "./LoadingCacheValue.mjs" + * @import { Loader } from "./types.mjs" + */ + +import React from "react"; + +/** + * React hook to create a memoized {@link Loader loader} from another, that + * automatically aborts previous loading that started via this hook when new + * loading starts via this hook, the hook arguments change, or the component + * unmounts. + * @param {Loader} load Memoized function that starts the loading. + * @returns {Loader} Memoized function that starts the loading. + */ +export default function useAutoAbortLoad(load) { + if (typeof load !== "function") + throw new TypeError("Argument 1 `load` must be a function."); + + const lastLoadingCacheValueRef = React.useRef( + /** @type {LoadingCacheValue | undefined} */ (undefined), + ); + + React.useEffect( + () => () => { + if (lastLoadingCacheValueRef.current) + // Abort the last loading as it’s now redundant due to the changed + // dependencies. Checking if it’s already ended or aborted first is + // unnecessary. + lastLoadingCacheValueRef.current.abortController.abort(); + }, + [load], + ); + + return React.useCallback(() => { + if (lastLoadingCacheValueRef.current) + // Ensure the last loading is aborted before starting new loading. + // Checking if it’s already ended or aborted first is unnecessary. + lastLoadingCacheValueRef.current.abortController.abort(); + + const loadingCacheValue = load(); + + lastLoadingCacheValueRef.current = loadingCacheValue; + + // After the loading cache value promise resolves, clear the ref (if it + // still holds the same loading cache value) to allow garbage collection. + // This might not be worth the bundle size increase. + loadingCacheValue.promise.then(() => { + if (lastLoadingCacheValueRef.current === loadingCacheValue) + lastLoadingCacheValueRef.current = undefined; + }); + + return loadingCacheValue; + }, [load]); +} diff --git a/useAutoAbortLoad.test.mjs b/useAutoAbortLoad.test.mjs new file mode 100644 index 0000000..68cd507 --- /dev/null +++ b/useAutoAbortLoad.test.mjs @@ -0,0 +1,194 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { notStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertTypeOf from "./test/assertTypeOf.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useAutoAbortLoad from "./useAutoAbortLoad.mjs"; + +describe("React hook `useAutoAbortLoad`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useAutoAbortLoad.mjs", import.meta.url), + 300, + ); + }); + + it("Argument 1 `load` not a function.", () => { + throws(() => { + useAutoAbortLoad( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `load` must be a function.")); + }); + + it("Functionality.", async () => { + const cache = new Cache(); + const loading = new Loading(); + + /** + * @type {Array<{ + * loader: Loader, + * hadArgs: boolean, + * loadingCacheValue: LoadingCacheValue + * }>} + */ + const loadCalls = []; + + /** @type {Loader} */ + function loadA() { + const loadingCacheValue = new LoadingCacheValue( + loading, + cache, + "a", + Promise.resolve(1), + new AbortController(), + ); + + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + loadingCacheValue, + }); + + return loadingCacheValue; + } + + /** @type {Loader} */ + function loadB() { + const loadingCacheValue = new LoadingCacheValue( + loading, + cache, + "a", + Promise.resolve(1), + new AbortController(), + ); + + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + loadingCacheValue, + }); + + return loadingCacheValue; + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useAutoAbortLoad(loadA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + assertTypeOf(results[0].returned, "function"); + strictEqual(loadCalls.length, 0); + + // Test that the returned auto abort load function is memoized. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + strictEqual(loadCalls.length, 0); + + // Start the first loading. + results[0].returned(); + + strictEqual(loadCalls.length, 1); + strictEqual(loadCalls[0].loader, loadA); + strictEqual(loadCalls[0].hadArgs, false); + strictEqual( + loadCalls[0].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Start the second loading, before the first ends. This should abort the + // first. + results[0].returned(); + + strictEqual(loadCalls.length, 2); + strictEqual( + loadCalls[0].loadingCacheValue.abortController.signal.aborted, + true, + ); + strictEqual(loadCalls[1].hadArgs, false); + strictEqual(loadCalls[1].loader, loadA); + strictEqual( + loadCalls[1].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Test that changing the loader causes the returned memoized auto abort + // load function to change, and the last loading to abort. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useAutoAbortLoad(loadB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + assertTypeOf(results[2].returned, "function"); + notStrictEqual(results[2].returned, results[1]); + strictEqual(loadCalls.length, 2); + strictEqual( + loadCalls[1].loadingCacheValue.abortController.signal.aborted, + true, + ); + + // Test that the returned newly memoized abort load function works. + results[2].returned(); + + strictEqual(loadCalls.length, 3); + strictEqual(loadCalls[2].loader, loadB); + strictEqual(loadCalls[2].hadArgs, false); + strictEqual( + loadCalls[2].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Test that the last loading is aborted on unmount. + ReactTestRenderer.act(() => { + testRenderer.unmount(); + }); + + strictEqual( + loadCalls[2].loadingCacheValue.abortController.signal.aborted, + true, + ); + }); +}); diff --git a/useAutoLoad.mjs b/useAutoLoad.mjs new file mode 100644 index 0000000..e5f81cc --- /dev/null +++ b/useAutoLoad.mjs @@ -0,0 +1,50 @@ +// @ts-check + +/** + * @import { CacheKey } from "./Cache.mjs" + * @import { Loader } from "./types.mjs" + * @import useWaterfallLoad from "./useWaterfallLoad.mjs" + */ + +import useAutoAbortLoad from "./useAutoAbortLoad.mjs"; +import useCacheEntryPrunePrevention from "./useCacheEntryPrunePrevention.mjs"; +import useLoadOnDelete from "./useLoadOnDelete.mjs"; +import useLoadOnMount from "./useLoadOnMount.mjs"; +import useLoadOnStale from "./useLoadOnStale.mjs"; + +/** + * React hook to prevent a {@link Cache.store cache store} entry from being + * pruned while the component is mounted and automatically keep it loaded. + * Previous loading that started via this hook aborts when new loading starts + * via this hook, the hook arguments change, or the component unmounts. + * @param {CacheKey} cacheKey Cache key. + * @param {Loader} load Memoized function that starts the loading. + * @returns {Loader} Memoized {@link Loader loader} created from the `load` + * argument, that automatically aborts the last loading when the memoized + * function changes or the component unmounts. + * @see {@link useCacheEntryPrunePrevention `useCacheEntryPrunePrevention`}, + * used by this hook. + * @see {@link useAutoAbortLoad `useAutoAbortLoad`}, used by this hook. + * @see {@link useLoadOnMount `useLoadOnMount`}, used by this hook. + * @see {@link useLoadOnStale `useLoadOnStale`}, used by this hook. + * @see {@link useLoadOnDelete `useLoadOnDelete`}, used by this hook. + * @see {@link useWaterfallLoad `useWaterfallLoad`}, often used alongside this + * hook for SSR loading. + */ +export default function useAutoLoad(cacheKey, load) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof load !== "function") + throw new TypeError("Argument 2 `load` must be a function."); + + useCacheEntryPrunePrevention(cacheKey); + + const autoAbortLoad = useAutoAbortLoad(load); + + useLoadOnMount(cacheKey, autoAbortLoad); + useLoadOnStale(cacheKey, autoAbortLoad); + useLoadOnDelete(cacheKey, autoAbortLoad); + + return autoAbortLoad; +} diff --git a/useAutoLoad.test.mjs b/useAutoLoad.test.mjs new file mode 100644 index 0000000..7f7a808 --- /dev/null +++ b/useAutoLoad.test.mjs @@ -0,0 +1,181 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; +import cacheEntryPrune from "./cacheEntryPrune.mjs"; +import cacheEntryStale from "./cacheEntryStale.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertTypeOf from "./test/assertTypeOf.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useAutoLoad from "./useAutoLoad.mjs"; + +describe("React hook `useAutoLoad`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./useAutoLoad.mjs", import.meta.url), 900); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useAutoLoad( + // @ts-expect-error Testing invalid. + true, + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ), + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Argument 2 `load` not a function.", () => { + throws(() => { + useAutoLoad( + "a", + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `load` must be a function.")); + }); + + it("Functionality.", async () => { + const cacheKey = "a"; + const cache = new Cache({ + // Populate the cache entry so it can be deleted. + [cacheKey]: 0, + }); + const loading = new Loading(); + + /** + * @type {Array<{ + * hadArgs: boolean, + * loadingCacheValue: LoadingCacheValue + * }>} + */ + const loadCalls = []; + + /** @type {Loader} */ + function load() { + const loadingCacheValue = new LoadingCacheValue( + loading, + cache, + cacheKey, + Promise.resolve(1), + new AbortController(), + ); + + loadCalls.push({ + hadArgs: !!arguments.length, + loadingCacheValue, + }); + + return loadingCacheValue; + } + + // Test load on mount. + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useAutoLoad(cacheKey, load), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + assertTypeOf(results[0].returned, "function"); + strictEqual(loadCalls.length, 1); + strictEqual(loadCalls[0].hadArgs, false); + strictEqual( + loadCalls[0].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Test that the returned auto abort load function is memoized, and that + // re-rendering doesn’t result in another load. + + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, results[0].returned); + strictEqual(loadCalls.length, 1); + + // Test prune prevention. + + cacheEntryPrune(cache, cacheKey); + + strictEqual(cacheKey in cache.store, true); + + // Test load on stale. + + cacheEntryStale(cache, cacheKey); + + strictEqual(loadCalls.length, 2); + strictEqual( + loadCalls[0].loadingCacheValue.abortController.signal.aborted, + true, + ); + strictEqual(loadCalls[1].hadArgs, false); + strictEqual( + loadCalls[1].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Test load on delete. + + cacheEntryDelete(cache, cacheKey); + + strictEqual(loadCalls.length, 3); + strictEqual( + loadCalls[1].loadingCacheValue.abortController.signal.aborted, + true, + ); + strictEqual(loadCalls[2].hadArgs, false); + strictEqual( + loadCalls[2].loadingCacheValue.abortController.signal.aborted, + false, + ); + + // Nothing should have caused a re-render. + strictEqual(results.length, 2); + + // Test that the last loading is aborted on unmount. + ReactTestRenderer.act(() => { + testRenderer.unmount(); + }); + + strictEqual( + loadCalls[2].loadingCacheValue.abortController.signal.aborted, + true, + ); + }); +}); diff --git a/useCache.mjs b/useCache.mjs new file mode 100644 index 0000000..76ce10b --- /dev/null +++ b/useCache.mjs @@ -0,0 +1,23 @@ +// @ts-check + +import React from "react"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; + +/** + * React hook to use the {@linkcode CacheContext}. + * @returns {Cache} The cache. + */ +export default function useCache() { + const cache = React.useContext(CacheContext); + + React.useDebugValue(cache); + + if (cache === undefined) throw new TypeError("Cache context missing."); + + if (!(cache instanceof Cache)) + throw new TypeError("Cache context value must be a `Cache` instance."); + + return cache; +} diff --git a/useCache.test.mjs b/useCache.test.mjs new file mode 100644 index 0000000..5501c96 --- /dev/null +++ b/useCache.test.mjs @@ -0,0 +1,105 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useCache from "./useCache.mjs"; + +describe("React hook `useCache`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./useCache.mjs", import.meta.url), 350); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: useCache, + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: useCache, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Getting the cache.", () => { + const cacheA = new Cache(); + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement(ReactHookTest, { + useHook: useCache, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, cacheA); + + const cacheB = new Cache(); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: useCache, + results, + }), + ), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, cacheB); + }); +}); diff --git a/useCacheEntry.mjs b/useCacheEntry.mjs new file mode 100644 index 0000000..2916fbf --- /dev/null +++ b/useCacheEntry.mjs @@ -0,0 +1,45 @@ +// @ts-check + +/** @import { CacheKey, CacheValue } from "./Cache.mjs" */ + +import React from "react"; + +import useCache from "./useCache.mjs"; +import useForceUpdate from "./useForceUpdate.mjs"; + +/** + * React hook to get a {@link CacheValue cache value} using its + * {@link CacheKey cache key}. + * @param {CacheKey} cacheKey Cache key. + * @returns {CacheValue} Cache value, if present. + */ +export default function useCacheEntry(cacheKey) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + const cache = useCache(); + const forceUpdate = useForceUpdate(); + + const onTriggerUpdate = React.useCallback(() => { + forceUpdate(); + }, [forceUpdate]); + + React.useEffect(() => { + const eventNameSet = `${cacheKey}/set`; + const eventNameDelete = `${cacheKey}/delete`; + + cache.addEventListener(eventNameSet, onTriggerUpdate); + cache.addEventListener(eventNameDelete, onTriggerUpdate); + + return () => { + cache.removeEventListener(eventNameSet, onTriggerUpdate); + cache.removeEventListener(eventNameDelete, onTriggerUpdate); + }; + }, [cache, cacheKey, onTriggerUpdate]); + + const value = cache.store[cacheKey]; + + React.useDebugValue(value); + + return value; +} diff --git a/useCacheEntry.test.mjs b/useCacheEntry.test.mjs new file mode 100644 index 0000000..50e419f --- /dev/null +++ b/useCacheEntry.test.mjs @@ -0,0 +1,289 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; +import cacheEntrySet from "./cacheEntrySet.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useCacheEntry from "./useCacheEntry.mjs"; + +describe("React hook `useCacheEntry`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useCacheEntry.mjs", import.meta.url), + 550, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useCacheEntry( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry("a"), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry("a"), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Without initial cache values for each cache key used.", () => { + const cache = new Cache(); + const cacheKeyA = "a"; + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry(cacheKeyA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + + const cacheValueA2 = "a2"; + + ReactTestRenderer.act(() => { + cacheEntrySet(cache, cacheKeyA, cacheValueA2); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, cacheValueA2); + + ReactTestRenderer.act(() => { + cacheEntryDelete(cache, cacheKeyA); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + + const cacheKeyB = "b"; + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry(cacheKeyB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + + const cacheValueB2 = "b2"; + + ReactTestRenderer.act(() => { + cacheEntrySet(cache, cacheKeyB, cacheValueB2); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, cacheValueB2); + + ReactTestRenderer.act(() => { + cacheEntryDelete(cache, cacheKeyB); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + }); + + it("Initial cache values for each cache key used, replacing cache values.", () => { + const cacheKeyA = "a"; + const cacheValueA1 = "a1"; + const cacheKeyB = "b"; + const cacheValueB1 = "b1"; + const cache = new Cache({ + [cacheKeyA]: cacheValueA1, + [cacheKeyB]: cacheValueB1, + }); + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry(cacheKeyA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, cacheValueA1); + + const cacheValueA2 = "a2"; + + ReactTestRenderer.act(() => { + cacheEntrySet(cache, cacheKeyA, cacheValueA2); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, cacheValueA2); + + ReactTestRenderer.act(() => { + cacheEntryDelete(cache, cacheKeyA); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry(cacheKeyB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, cacheValueB1); + + const cacheValueB2 = "b2"; + + ReactTestRenderer.act(() => { + cacheEntrySet(cache, cacheKeyB, cacheValueB2); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, cacheValueB2); + + ReactTestRenderer.act(() => { + cacheEntryDelete(cache, cacheKeyB); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + }); + + it("Initial cache value, mutating cache value.", () => { + const cacheKey = "a"; + const cacheValue = { a: 1 }; + const cache = new Cache({ + [cacheKey]: cacheValue, + }); + + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntry(cacheKey), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, cacheValue); + + ReactTestRenderer.act(() => { + cacheValue.a = 2; + cache.dispatchEvent( + new CustomEvent(`${cacheKey}/set`, { + detail: { + cacheValue, + }, + }), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, cacheValue); + + ReactTestRenderer.act(() => { + cacheEntryDelete(cache, cacheKey); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + }); +}); diff --git a/useCacheEntryPrunePrevention.mjs b/useCacheEntryPrunePrevention.mjs new file mode 100644 index 0000000..cae63c9 --- /dev/null +++ b/useCacheEntryPrunePrevention.mjs @@ -0,0 +1,38 @@ +// @ts-check + +/** @import Cache, { CacheEventMap, CacheKey } from "./Cache.mjs" */ + +import React from "react"; + +import useCache from "./useCache.mjs"; + +/** + * React hook to prevent a {@link Cache.store cache store} entry from being + * pruned, by canceling the cache entry deletion for + * {@link CacheEventMap.prune `prune`} events with `event.preventDefault()`. + * @param {CacheKey} cacheKey Cache key. + */ +export default function useCacheEntryPrunePrevention(cacheKey) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + const cache = useCache(); + + React.useEffect(() => { + const eventNamePrune = `${cacheKey}/prune`; + + cache.addEventListener(eventNamePrune, cancelEvent); + + return () => { + cache.removeEventListener(eventNamePrune, cancelEvent); + }; + }, [cache, cacheKey]); +} + +/** + * Cancels an event. + * @param {Event} event Event. + */ +function cancelEvent(event) { + event.preventDefault(); +} diff --git a/useCacheEntryPrunePrevention.test.mjs b/useCacheEntryPrunePrevention.test.mjs new file mode 100644 index 0000000..1376abb --- /dev/null +++ b/useCacheEntryPrunePrevention.test.mjs @@ -0,0 +1,158 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheEntryPrune from "./cacheEntryPrune.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useCacheEntryPrunePrevention from "./useCacheEntryPrunePrevention.mjs"; + +describe( + "React hook `useCacheEntryPrunePrevention`.", + { concurrency: true }, + () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useCacheEntryPrunePrevention.mjs", import.meta.url), + 450, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useCacheEntryPrunePrevention( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useCacheEntryPrunePrevention("a"), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context missing."), + ); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntryPrunePrevention("a"), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Functionality.", () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const initialCacheStore = { + [cacheKeyA]: 1, + [cacheKeyB]: 2, + }; + const cache = new Cache({ ...initialCacheStore }); + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntryPrunePrevention(cacheKeyA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + + ReactTestRenderer.act(() => { + // This cache entry prune should be prevented. + cacheEntryPrune(cache, cacheKeyA); + }); + + deepStrictEqual(cache.store, initialCacheStore); + + strictEqual(results.length, 1); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => useCacheEntryPrunePrevention(cacheKeyB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + + ReactTestRenderer.act(() => { + // This cache entry prune should be prevented. + cacheEntryPrune(cache, cacheKeyB); + }); + + deepStrictEqual(cache.store, initialCacheStore); + + strictEqual(results.length, 2); + + ReactTestRenderer.act(() => { + // This cache entry prune should no longer be prevented. + cacheEntryPrune(cache, cacheKeyA); + }); + + deepStrictEqual(cache.store, { [cacheKeyB]: 2 }); + + strictEqual(results.length, 2); + }); + }, +); diff --git a/useForceUpdate.mjs b/useForceUpdate.mjs new file mode 100644 index 0000000..c1f9373 --- /dev/null +++ b/useForceUpdate.mjs @@ -0,0 +1,13 @@ +// @ts-check + +import React from "react"; + +/** + * React hook to force the component to update and re-render on demand. + * @returns {() => void} Function that forces an update. + * @see [React hooks FAQ](https://reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate). + * @see [Gotcha explanation](https://github.com/CharlesStover/use-force-update/issues/18#issuecomment-554486618). + */ +export default function useForceUpdate() { + return React.useReducer(() => Symbol(), Symbol())[1]; +} diff --git a/useForceUpdate.test.mjs b/useForceUpdate.test.mjs new file mode 100644 index 0000000..c3ebf9e --- /dev/null +++ b/useForceUpdate.test.mjs @@ -0,0 +1,54 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import { ok, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import assertTypeOf from "./test/assertTypeOf.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useForceUpdate from "./useForceUpdate.mjs"; + +describe("React hook `useForceUpdate`.", { concurrency: true }, () => { + it("Forcing an update.", async () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: useForceUpdate, + results, + }), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + + const result1Returned = results[0].returned; + + assertTypeOf(result1Returned, "function"); + + ReactTestRenderer.act(() => { + result1Returned(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + + const result2Returned = results[1].returned; + + assertTypeOf(result2Returned, "function"); + + ReactTestRenderer.act(() => { + result2Returned(); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + assertTypeOf(results[2].returned, "function"); + }); +}); diff --git a/useLoadGraphQL.mjs b/useLoadGraphQL.mjs new file mode 100644 index 0000000..177164a --- /dev/null +++ b/useLoadGraphQL.mjs @@ -0,0 +1,90 @@ +// @ts-check + +/** @import { CacheKey } from "./Cache.mjs" */ + +import React from "react"; + +import fetchGraphQL from "./fetchGraphQL.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import useCache from "./useCache.mjs"; +import useLoading from "./useLoading.mjs"; + +/** + * React hook to get a function for loading a GraphQL operation. + * @returns {LoadGraphQL} Loads a GraphQL operation. + */ +export default function useLoadGraphQL() { + const cache = useCache(); + const loading = useLoading(); + + return React.useCallback( + (cacheKey, fetchUri, fetchOptions) => { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof fetchUri !== "string") + throw new TypeError("Argument 2 `fetchUri` must be a string."); + + if ( + typeof fetchOptions !== "object" || + !fetchOptions || + Array.isArray(fetchOptions) + ) + throw new TypeError("Argument 3 `fetchOptions` must be an object."); + + /** @type {RequestInit["signal"]} */ + let signal; + + /** + * Fetch options, modified without mutating the input. + * @type {RequestInit} + */ + let modifiedFetchOptions; + + ({ signal, ...modifiedFetchOptions } = fetchOptions); + + const abortController = new AbortController(); + + // Respect an existing abort controller signal. + if (signal) + signal.aborted + ? // Signal already aborted, so immediately abort. + abortController.abort() + : // Signal not already aborted, so setup a listener to abort when it + // does. + signal.addEventListener( + "abort", + () => { + abortController.abort(); + }, + { + // Prevent a memory leak if the existing abort controller is + // long lasting, or controls multiple things. + once: true, + }, + ); + + modifiedFetchOptions.signal = abortController.signal; + + return new LoadingCacheValue( + loading, + cache, + cacheKey, + fetchGraphQL(fetchUri, modifiedFetchOptions), + abortController, + ); + }, + [cache, loading], + ); +} + +/** + * Loads a GraphQL operation, using {@linkcode fetchGraphQL}. + * @callback LoadGraphQL + * @param {CacheKey} cacheKey Cache key to store the loading result under. + * @param {string} fetchUri [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) + * URI. + * @param {RequestInit} fetchOptions [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) + * options. + * @returns {LoadingCacheValue} The loading cache value. + */ diff --git a/useLoadGraphQL.test.mjs b/useLoadGraphQL.test.mjs new file mode 100644 index 0000000..2d6623d --- /dev/null +++ b/useLoadGraphQL.test.mjs @@ -0,0 +1,566 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, fail, ok, strictEqual, throws } from "node:assert"; +import { after, describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; +import revertableGlobals from "revertable-globals"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheDelete from "./cacheDelete.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import LoadingContext from "./LoadingContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import assertInstanceOf from "./test/assertInstanceOf.mjs"; +import assertTypeOf from "./test/assertTypeOf.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoadGraphQL from "./useLoadGraphQL.mjs"; + +describe("React hook `useLoadGraphQL`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useLoadGraphQL.mjs", import.meta.url), + 1800, + ); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: useLoadGraphQL, + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: useLoadGraphQL, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Loading context missing.", () => { + const cache = new Cache(); + + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: useLoadGraphQL, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context missing."), + ); + }); + + it("Loading context value not a `Loading` instance.", () => { + const cache = new Cache(); + + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement( + LoadingContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: useLoadGraphQL, + results, + }), + ), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context value must be a `Loading` instance."), + ); + }); + + describe( + "Functionality.", + { + // Some of the tests temporarily modify the global `fetch`. + concurrency: false, + }, + async () => { + const cache = new Cache(); + const loading = new Loading(); + + /** + * @type {Array< + * ReactHookResult< + * ReturnType + * > + * >} + */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement( + LoadingContext.Provider, + { value: loading }, + React.createElement(ReactHookTest, { + useHook: useLoadGraphQL, + results, + }), + ), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + assertTypeOf(results[0].returned, "function"); + + // Test that re-rendering with the same props doesn’t cause the returned + // load GraphQL function to change. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + + const result2Returned = results[1].returned; + + strictEqual(result2Returned, results[0].returned); + + after(() => { + // No re-rendering should have happened. + strictEqual(results.length, 2); + }); + + it("Load GraphQL with argument 1 `cacheKey` not a string.", () => { + throws(() => { + result2Returned( + // @ts-expect-error Testing invalid. + true, + "", + {}, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Load GraphQL with argument 2 `fetchUri` not a string.", () => { + throws(() => { + result2Returned( + "a", + // @ts-expect-error Testing invalid. + true, + {}, + ); + }, new TypeError("Argument 2 `fetchUri` must be a string.")); + }); + + it("Load GraphQL with argument 3 `fetchOptions` not an object.", () => { + throws(() => { + result2Returned( + "a", + "", + // @ts-expect-error Testing invalid. + null, + ); + }, new TypeError("Argument 3 `fetchOptions` must be an object.")); + }); + + it("Load GraphQL without aborting.", async () => { + const fetchUri = "the-uri"; + const fetchOptions = Object.freeze({ body: "a" }); + const cacheKey = "a"; + const cacheValue = { + data: { + a: 1, + }, + }; + + /** @type {string | undefined} */ + let fetchedUri; + + /** @type {RequestInit | undefined} */ + let fetchedOptions; + + /** @type {LoadingCacheValue | undefined} */ + let loadGraphQLReturn; + + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + + return new Response(JSON.stringify(cacheValue), { + status: 200, + headers: { + "Content-Type": "application/graphql+json", + }, + }); + }, + }); + + try { + try { + ReactTestRenderer.act(() => { + loadGraphQLReturn = result2Returned( + cacheKey, + fetchUri, + fetchOptions, + ); + }); + } finally { + revertGlobals(); + } + + strictEqual(fetchedUri, fetchUri); + assertTypeOf(fetchedOptions, "object"); + + const { signal: fetchedOptionsSignal, ...fetchedOptionsRest } = + fetchedOptions; + + assertInstanceOf(fetchedOptionsSignal, AbortSignal); + deepStrictEqual(fetchedOptionsRest, fetchOptions); + + assertInstanceOf(loadGraphQLReturn, LoadingCacheValue); + deepStrictEqual(await loadGraphQLReturn.promise, cacheValue); + deepStrictEqual(cache.store, { + [cacheKey]: cacheValue, + }); + } finally { + // Undo any cache changes for future tests. + cacheDelete(cache); + } + }); + + it("Load GraphQL aborting, no fetch options `signal`.", async () => { + const fetchUri = "the-uri"; + const fetchOptions = Object.freeze({ body: "a" }); + const fetchError = new Error("The operation was aborted."); + const cacheKey = "a"; + + /** @type {string | undefined} */ + let fetchedUri; + + /** @type {RequestInit | undefined} */ + let fetchedOptions; + + /** @type {LoadingCacheValue | undefined} */ + let loadGraphQLReturn; + + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(fail("Fetch wasn’t aborted.")); + }, 800); + + assertInstanceOf(options.signal, AbortSignal); + + options.signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(fetchError); + }, + { once: true }, + ); + }); + }, + }); + + try { + try { + ReactTestRenderer.act(() => { + loadGraphQLReturn = result2Returned( + cacheKey, + fetchUri, + fetchOptions, + ); + }); + } finally { + revertGlobals(); + } + + strictEqual(fetchedUri, fetchUri); + assertTypeOf(fetchedOptions, "object"); + + const { signal: fetchedOptionsSignal, ...fetchedOptionsRest } = + fetchedOptions; + + assertInstanceOf(fetchedOptionsSignal, AbortSignal); + deepStrictEqual(fetchedOptionsRest, fetchOptions); + assertInstanceOf(loadGraphQLReturn, LoadingCacheValue); + + loadGraphQLReturn.abortController.abort(); + + deepStrictEqual(await loadGraphQLReturn.promise, { + errors: [ + { + message: "Fetch error.", + extensions: { + client: true, + code: "FETCH_ERROR", + fetchErrorMessage: fetchError.message, + }, + }, + ], + }); + deepStrictEqual( + cache.store, + // Cache shouldn’t be affected by aborted loading. + {}, + ); + } finally { + // Undo any cache changes for future tests. + cacheDelete(cache); + } + }); + + it("Load GraphQL aborting, fetch options `signal`, not yet aborted.", async () => { + const fetchUri = "the-uri"; + const abortController = new AbortController(); + const fetchOptionsWithoutSignal = { body: "a" }; + const fetchOptions = Object.freeze({ + ...fetchOptionsWithoutSignal, + signal: abortController.signal, + }); + const fetchError = new Error("The operation was aborted."); + const cacheKey = "a"; + + /** @type {string | undefined} */ + let fetchedUri; + + /** @type {RequestInit | undefined} */ + let fetchedOptions; + + /** @type {LoadingCacheValue | undefined} */ + let loadGraphQLReturn; + + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(fail("Fetch wasn’t aborted.")); + }, 800); + + assertInstanceOf(options.signal, AbortSignal); + + options.signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(fetchError); + }, + { once: true }, + ); + }); + }, + }); + + try { + try { + ReactTestRenderer.act(() => { + loadGraphQLReturn = result2Returned( + cacheKey, + fetchUri, + fetchOptions, + ); + }); + } finally { + revertGlobals(); + } + + strictEqual(fetchedUri, fetchUri); + assertTypeOf(fetchedOptions, "object"); + + const { signal: fetchedOptionsSignal, ...fetchedOptionsRest } = + fetchedOptions; + + assertInstanceOf(fetchedOptionsSignal, AbortSignal); + deepStrictEqual(fetchedOptionsRest, fetchOptionsWithoutSignal); + assertInstanceOf(loadGraphQLReturn, LoadingCacheValue); + + abortController.abort(); + + deepStrictEqual(await loadGraphQLReturn.promise, { + errors: [ + { + message: "Fetch error.", + extensions: { + client: true, + code: "FETCH_ERROR", + fetchErrorMessage: fetchError.message, + }, + }, + ], + }); + deepStrictEqual( + cache.store, + // Cache shouldn’t be affected by aborted loading. + {}, + ); + } finally { + // Undo any cache changes for future tests. + cacheDelete(cache); + } + }); + + it("Load GraphQL aborting, fetch options `signal`, already aborted.", async () => { + const fetchUri = "the-uri"; + const abortController = new AbortController(); + + abortController.abort(); + + const fetchOptionsWithoutSignal = { body: "a" }; + const fetchOptions = Object.freeze({ + ...fetchOptionsWithoutSignal, + signal: abortController.signal, + }); + const fetchError = new Error("The operation was aborted."); + const cacheKey = "a"; + + /** @type {string | undefined} */ + let fetchedUri; + + /** @type {RequestInit | undefined} */ + let fetchedOptions; + + /** @type {LoadingCacheValue | undefined} */ + let loadGraphQLReturn; + + const revertGlobals = revertableGlobals({ + /** + * @param {string} uri Fetch URI. + * @param {RequestInit} options Fetch options. + */ + async fetch(uri, options) { + fetchedUri = uri; + fetchedOptions = options; + + assertInstanceOf(options.signal, AbortSignal); + + throw options.signal.aborted + ? fetchError + : fail("Abort signal wasn’t already aborted."); + }, + }); + + try { + try { + ReactTestRenderer.act(() => { + loadGraphQLReturn = result2Returned( + cacheKey, + fetchUri, + fetchOptions, + ); + }); + } finally { + revertGlobals(); + } + + strictEqual(fetchedUri, fetchUri); + assertTypeOf(fetchedOptions, "object"); + + const { signal: fetchedOptionsSignal, ...fetchedOptionsRest } = + fetchedOptions; + + assertInstanceOf(fetchedOptionsSignal, AbortSignal); + deepStrictEqual(fetchedOptionsRest, fetchOptionsWithoutSignal); + assertInstanceOf(loadGraphQLReturn, LoadingCacheValue); + deepStrictEqual(await loadGraphQLReturn.promise, { + errors: [ + { + message: "Fetch error.", + extensions: { + client: true, + code: "FETCH_ERROR", + fetchErrorMessage: fetchError.message, + }, + }, + ], + }); + deepStrictEqual( + cache.store, + // Cache shouldn’t be affected by aborted loading. + {}, + ); + } finally { + // Undo any cache changes for future tests. + cacheDelete(cache); + } + }); + }, + ); +}); diff --git a/useLoadOnDelete.mjs b/useLoadOnDelete.mjs new file mode 100644 index 0000000..79bfa32 --- /dev/null +++ b/useLoadOnDelete.mjs @@ -0,0 +1,41 @@ +// @ts-check + +/** + * @import { CacheEventMap, CacheKey } from "./Cache.mjs" + * @import { Loader } from "./types.mjs" + */ + +import React from "react"; + +import useCache from "./useCache.mjs"; + +/** + * React hook to load a {@link Cache.store cache store} entry after it’s + * {@link CacheEventMap.delete deleted}, if there isn’t loading for the + * {@link CacheKey cache key} that started after. + * @param {CacheKey} cacheKey Cache key. + * @param {Loader} load Memoized function that starts the loading. + */ +export default function useLoadOnDelete(cacheKey, load) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof load !== "function") + throw new TypeError("Argument 2 `load` must be a function."); + + const cache = useCache(); + + const onCacheEntryDelete = React.useCallback(() => { + load(); + }, [load]); + + React.useEffect(() => { + const eventNameDelete = `${cacheKey}/delete`; + + cache.addEventListener(eventNameDelete, onCacheEntryDelete); + + return () => { + cache.removeEventListener(eventNameDelete, onCacheEntryDelete); + }; + }, [cache, cacheKey, onCacheEntryDelete]); +} diff --git a/useLoadOnDelete.test.mjs b/useLoadOnDelete.test.mjs new file mode 100644 index 0000000..9ce0033 --- /dev/null +++ b/useLoadOnDelete.test.mjs @@ -0,0 +1,268 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheEntryDelete from "./cacheEntryDelete.mjs"; +import cacheEntrySet from "./cacheEntrySet.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoadOnDelete from "./useLoadOnDelete.mjs"; + +describe("React hook `useLoadOnDelete`.", { concurrency: true }, () => { + /** + * Dummy loader for testing. + * @type {Loader} + */ + const dummyLoader = () => + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ); + + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useLoadOnDelete.mjs", import.meta.url), + 500, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useLoadOnDelete( + // @ts-expect-error Testing invalid. + true, + dummyLoader, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Argument 2 `load` not a function.", () => { + throws(() => { + useLoadOnDelete( + "a", + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `load` must be a function.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete("a", dummyLoader), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete("a", dummyLoader), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Functionality.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache({ + // Populate the cache entry so it can be deleted. + [cacheKeyA]: 0, + }); + const cacheB = new Cache({ + // Populate the cache entries so they can be deleted. + [cacheKeyA]: 0, + [cacheKeyB]: 0, + }); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete(cacheKeyA, loadA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + + cacheEntryDelete(cacheA, cacheKeyA); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with the a different cache causes the listener + // to be moved to the new cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete(cacheKeyA, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + + cacheEntryDelete(cacheB, cacheKeyA); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with a different cache key causes the listener + // to be updated. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete(cacheKeyB, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + + cacheEntryDelete(cacheB, cacheKeyB); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with a different loader causes the listener + // to be updated. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnDelete(cacheKeyB, loadB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + + // Repopulate the cache entry with any value so it can be deleted again. + cacheEntrySet(cacheB, cacheKeyB, 0); + cacheEntryDelete(cacheB, cacheKeyB); + + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + // Nothing should have caused a re-render. + strictEqual(results.length, 4); + }); +}); diff --git a/useLoadOnMount.mjs b/useLoadOnMount.mjs new file mode 100644 index 0000000..39d9c03 --- /dev/null +++ b/useLoadOnMount.mjs @@ -0,0 +1,73 @@ +// @ts-check + +/** + * @import Cache, { CacheKey } from "./Cache.mjs" + * @import { Loader } from "./types.mjs" + */ + +import React from "react"; + +import HYDRATION_TIME_MS from "./HYDRATION_TIME_MS.mjs"; +import HydrationTimeStampContext from "./HydrationTimeStampContext.mjs"; +import useCache from "./useCache.mjs"; + +/** + * React hook to automatically load a {@link Cache.store cache store} entry + * after the component mounts or the {@link CacheContext cache context} or any + * of the arguments change, except during the + * {@link HYDRATION_TIME_MS hydration time} if the + * {@link HydrationTimeStampContext hydration time stamp context} is populated + * and the {@link Cache.store cache store} entry is already populated. + * @param {CacheKey} cacheKey Cache key. + * @param {Loader} load Memoized function that starts the loading. + */ +export default function useLoadOnMount(cacheKey, load) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof load !== "function") + throw new TypeError("Argument 2 `load` must be a function."); + + const cache = useCache(); + const hydrationTimeStamp = React.useContext(HydrationTimeStampContext); + + if ( + // Allowed to be undefined for apps that don’t provide this context. + hydrationTimeStamp !== undefined && + typeof hydrationTimeStamp !== "number" + ) + throw new TypeError("Hydration time stamp context value must be a number."); + + const startedRef = React.useRef( + /** + * @type {{ + * cache: Cache, + * cacheKey: CacheKey, + * load: Loader, + * } | undefined} + */ (undefined), + ); + + React.useEffect(() => { + if ( + // Loading the same as currently specified wasn’t already started. + !( + startedRef.current && + startedRef.current.cache === cache && + startedRef.current.cacheKey === cacheKey && + startedRef.current.load === load + ) && + // Waterfall loaded cache isn’t being hydrated. + !( + cacheKey in cache.store && + hydrationTimeStamp && + // Within the hydration time. + performance.now() - hydrationTimeStamp < HYDRATION_TIME_MS + ) + ) { + startedRef.current = { cache, cacheKey, load }; + + load(); + } + }, [cache, cacheKey, hydrationTimeStamp, load]); +} diff --git a/useLoadOnMount.test.mjs b/useLoadOnMount.test.mjs new file mode 100644 index 0000000..2d818c2 --- /dev/null +++ b/useLoadOnMount.test.mjs @@ -0,0 +1,951 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import HYDRATION_TIME_MS from "./HYDRATION_TIME_MS.mjs"; +import HydrationTimeStampContext from "./HydrationTimeStampContext.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoadOnMount from "./useLoadOnMount.mjs"; + +describe("React hook `useLoadOnMount`.", { concurrency: true }, () => { + /** + * Dummy loader for testing. + * @type {Loader} + */ + const dummyLoader = () => + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ); + + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useLoadOnMount.mjs", import.meta.url), + 600, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useLoadOnMount( + // @ts-expect-error Testing invalid. + true, + dummyLoader, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Argument 2 `load` not a function.", () => { + throws(() => { + useLoadOnMount( + "a", + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `load` must be a function.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount("a", dummyLoader), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount("a", dummyLoader), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Hydration time stamp context value not undefined or a number.", () => { + const cache = new Cache(); + + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement( + HydrationTimeStampContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount("a", dummyLoader), + results, + }), + ), + ), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Hydration time stamp context value must be a number."), + ); + }); + + it("Hydration time stamp context undefined, without initial cache values.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache(); + const cacheB = new Cache(); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with the a different cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[2].rerender(); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different cache key. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[4].rerender(); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different loader. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 7); + ok("returned" in results[6]); + strictEqual(results[6].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[6].rerender(); + }); + + strictEqual(results.length, 8); + ok("returned" in results[7]); + strictEqual(results[7].returned, undefined); + deepStrictEqual(loadCalls, []); + }); + + it("Hydration time stamp context undefined, with initial cache values.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache({ + [cacheKeyA]: 0, + }); + const cacheB = new Cache({ + [cacheKeyA]: 0, + [cacheKeyB]: 0, + }); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with the a different cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[2].rerender(); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different cache key. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[4].rerender(); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different loader. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 7); + ok("returned" in results[6]); + strictEqual(results[6].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[6].rerender(); + }); + + strictEqual(results.length, 8); + ok("returned" in results[7]); + strictEqual(results[7].returned, undefined); + deepStrictEqual(loadCalls, []); + }); + + it("Hydration time stamp context defined, without initial cache values.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache(); + const cacheB = new Cache(); + const hydrationTimeStamp = performance.now(); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with the a different cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[2].rerender(); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different cache key. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadA), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[4].rerender(); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different loader. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadB), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 7); + ok("returned" in results[6]); + strictEqual(results[6].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[6].rerender(); + }); + + strictEqual(results.length, 8); + ok("returned" in results[7]); + strictEqual(results[7].returned, undefined); + deepStrictEqual(loadCalls, []); + }); + + it("Hydration time stamp context defined, with initial cache values.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache({ + [cacheKeyA]: 0, + }); + const cacheB = new Cache({ + [cacheKeyA]: 0, + [cacheKeyB]: 0, + }); + const hydrationTimeStamp = performance.now(); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[0].rerender(); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with the a different cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[2].rerender(); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different cache key. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadA), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + strictEqual(results[4].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[4].rerender(); + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test re-rendering with a different loader. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadB), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 7); + ok("returned" in results[6]); + strictEqual(results[6].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Test that re-rendering doesn’t cause another load. + ReactTestRenderer.act(() => { + results[6].rerender(); + }); + + strictEqual(results.length, 8); + ok("returned" in results[7]); + strictEqual(results[7].returned, undefined); + deepStrictEqual(loadCalls, []); + + // Wait for the hydration time to expire. + await new Promise((resolve) => setTimeout(resolve, HYDRATION_TIME_MS + 50)); + + // Test re-rendering with the a different cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyB, loadB), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 9); + ok("returned" in results[8]); + strictEqual(results[8].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test re-rendering with the a different cache key. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadB), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 10); + ok("returned" in results[9]); + strictEqual(results[9].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test re-rendering with the a different loader. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement( + HydrationTimeStampContext.Provider, + { value: hydrationTimeStamp }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnMount(cacheKeyA, loadA), + results, + }), + ), + ), + ); + }); + + strictEqual(results.length, 11); + ok("returned" in results[10]); + strictEqual(results[10].returned, undefined); + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + }); +}); diff --git a/useLoadOnStale.mjs b/useLoadOnStale.mjs new file mode 100644 index 0000000..c26c46d --- /dev/null +++ b/useLoadOnStale.mjs @@ -0,0 +1,41 @@ +// @ts-check + +/** + * @import { CacheEventMap, CacheKey } from "./Cache.mjs" + * @import { Loader } from "./types.mjs" + */ + +import React from "react"; + +import useCache from "./useCache.mjs"; + +/** + * React hook to load a {@link Cache.store cache store} entry after becomes + * {@link CacheEventMap.stale stale}, if there isn’t loading for the + * {@link CacheKey cache key} that started after. + * @param {CacheKey} cacheKey Cache key. + * @param {Loader} load Memoized function that starts the loading. + */ +export default function useLoadOnStale(cacheKey, load) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof load !== "function") + throw new TypeError("Argument 2 `load` must be a function."); + + const cache = useCache(); + + const onCacheEntryStale = React.useCallback(() => { + load(); + }, [load]); + + React.useEffect(() => { + const eventNameStale = `${cacheKey}/stale`; + + cache.addEventListener(eventNameStale, onCacheEntryStale); + + return () => { + cache.removeEventListener(eventNameStale, onCacheEntryStale); + }; + }, [cache, cacheKey, onCacheEntryStale]); +} diff --git a/useLoadOnStale.test.mjs b/useLoadOnStale.test.mjs new file mode 100644 index 0000000..408cfb1 --- /dev/null +++ b/useLoadOnStale.test.mjs @@ -0,0 +1,265 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import cacheEntryStale from "./cacheEntryStale.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoadOnStale from "./useLoadOnStale.mjs"; + +describe("React hook `useLoadOnStale`.", { concurrency: true }, () => { + /** + * Dummy loader for testing. + * @type {Loader} + */ + const dummyLoader = () => + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ); + + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useLoadOnStale.mjs", import.meta.url), + 500, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useLoadOnStale( + // @ts-expect-error Testing invalid. + true, + dummyLoader, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Argument 2 `load` not a function.", () => { + throws(() => { + useLoadOnStale( + "a", + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `load` must be a function.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale("a", dummyLoader), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale("a", dummyLoader), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Functionality.", async () => { + const cacheKeyA = "a"; + const cacheKeyB = "b"; + const cacheA = new Cache({ + // Populate the cache entry so it can be staled. + [cacheKeyA]: 0, + }); + const cacheB = new Cache({ + // Populate the cache entries so they can be staled. + [cacheKeyA]: 0, + [cacheKeyB]: 0, + }); + + /** @type {Array<{ loader: Function, hadArgs: boolean }>} */ + let loadCalls = []; + + /** @type {Loader} */ + function loadA() { + loadCalls.push({ + loader: loadA, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Loader} */ + function loadB() { + loadCalls.push({ + loader: loadB, + hadArgs: !!arguments.length, + }); + + return dummyLoader(); + } + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cacheA }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale(cacheKeyA, loadA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + + cacheEntryStale(cacheA, cacheKeyA); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with the a different cache causes the listener + // to be moved to the new cache. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale(cacheKeyA, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, undefined); + + cacheEntryStale(cacheB, cacheKeyA); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with a different cache key causes the listener + // to be updated. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale(cacheKeyB, loadA), + results, + }), + ), + ); + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + + cacheEntryStale(cacheB, cacheKeyB); + + deepStrictEqual(loadCalls, [ + { + loader: loadA, + hadArgs: false, + }, + ]); + + loadCalls = []; + + // Test that re-rendering with a different loader causes the listener + // to be updated. + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + CacheContext.Provider, + { value: cacheB }, + React.createElement(ReactHookTest, { + useHook: () => useLoadOnStale(cacheKeyB, loadB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + + cacheEntryStale(cacheB, cacheKeyB); + + deepStrictEqual(loadCalls, [ + { + loader: loadB, + hadArgs: false, + }, + ]); + + // Nothing should have caused a re-render. + strictEqual(results.length, 4); + }); +}); diff --git a/useLoading.mjs b/useLoading.mjs new file mode 100644 index 0000000..4f3e9e7 --- /dev/null +++ b/useLoading.mjs @@ -0,0 +1,23 @@ +// @ts-check + +import React from "react"; + +import Loading from "./Loading.mjs"; +import LoadingContext from "./LoadingContext.mjs"; + +/** + * React hook to use the {@linkcode CacheContext}. + * @returns {Loading} Loading. + */ +export default function useLoading() { + const loading = React.useContext(LoadingContext); + + React.useDebugValue(loading); + + if (loading === undefined) throw new TypeError("Loading context missing."); + + if (!(loading instanceof Loading)) + throw new TypeError("Loading context value must be a `Loading` instance."); + + return loading; +} diff --git a/useLoading.test.mjs b/useLoading.test.mjs new file mode 100644 index 0000000..760f823 --- /dev/null +++ b/useLoading.test.mjs @@ -0,0 +1,108 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Loading from "./Loading.mjs"; +import LoadingContext from "./LoadingContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoading from "./useLoading.mjs"; + +describe("React hook `useLoading`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize(new URL("./useLoading.mjs", import.meta.url), 300); + }); + + it("Loading context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: useLoading, + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context missing."), + ); + }); + + it("Loading context value not a `Loading` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: useLoading, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context value must be a `Loading` instance."), + ); + }); + + it("Getting the loading.", () => { + const loadingA = new Loading(); + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { value: loadingA }, + React.createElement(ReactHookTest, { + useHook: useLoading, + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, loadingA); + + const loadingB = new Loading(); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + LoadingContext.Provider, + { value: loadingB }, + React.createElement(ReactHookTest, { + useHook: useLoading, + results, + }), + ), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + strictEqual(results[1].returned, loadingB); + }); +}); diff --git a/useLoadingEntry.mjs b/useLoadingEntry.mjs new file mode 100644 index 0000000..3c29945 --- /dev/null +++ b/useLoadingEntry.mjs @@ -0,0 +1,49 @@ +// @ts-check + +/** + * @import { CacheKey } from "./Cache.mjs" + * @import LoadingCacheValue from "./LoadingCacheValue.mjs" + */ + +import React from "react"; + +import useForceUpdate from "./useForceUpdate.mjs"; +import useLoading from "./useLoading.mjs"; + +/** + * React hook to get the {@link LoadingCacheValue loading cache values} for a + * given {@link CacheKey cache key}. + * @param {CacheKey} cacheKey Cache key. + * @returns {Set | undefined} Loading cache values, if + * present. + */ +export default function useLoadingEntry(cacheKey) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + const loading = useLoading(); + const forceUpdate = useForceUpdate(); + + const onTriggerUpdate = React.useCallback(() => { + forceUpdate(); + }, [forceUpdate]); + + React.useEffect(() => { + const eventNameStart = `${cacheKey}/start`; + const eventNameEnd = `${cacheKey}/end`; + + loading.addEventListener(eventNameStart, onTriggerUpdate); + loading.addEventListener(eventNameEnd, onTriggerUpdate); + + return () => { + loading.removeEventListener(eventNameStart, onTriggerUpdate); + loading.removeEventListener(eventNameEnd, onTriggerUpdate); + }; + }, [loading, cacheKey, onTriggerUpdate]); + + const value = loading.store[cacheKey]; + + React.useDebugValue(value); + + return value; +} diff --git a/useLoadingEntry.test.mjs b/useLoadingEntry.test.mjs new file mode 100644 index 0000000..c5bc16f --- /dev/null +++ b/useLoadingEntry.test.mjs @@ -0,0 +1,305 @@ +// @ts-check + +/** @import { ReactHookResult } from "./test/ReactHookTest.mjs" */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactTestRenderer from "react-test-renderer"; + +import Cache from "./Cache.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import LoadingContext from "./LoadingContext.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import Deferred from "./test/Deferred.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useLoadingEntry from "./useLoadingEntry.mjs"; + +describe("React hook `useLoadingEntry`.", { concurrency: true }, () => { + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useLoadingEntry.mjs", import.meta.url), + 500, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useLoadingEntry( + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Loading context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry("a"), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context missing."), + ); + }); + + it("Loading context value not a `Loading` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry("a"), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Loading context value must be a `Loading` instance."), + ); + }); + + it("Without initial loading for each cache key used.", async () => { + const loading = new Loading(); + const cache = new Cache(); + const cacheKeyA = "a"; + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { value: loading }, + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry(cacheKeyA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, undefined); + + const { promise: loadingA1Result, resolve: loadingA1ResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + + /** @type {LoadingCacheValue | undefined} */ + let loadingA1CacheValue; + + ReactTestRenderer.act(() => { + loadingA1CacheValue = new LoadingCacheValue( + loading, + cache, + cacheKeyA, + loadingA1Result, + new AbortController(), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + deepStrictEqual(results[1].returned, new Set([loadingA1CacheValue])); + + await ReactTestRenderer.act(async () => { + loadingA1ResultResolve({}); + await /** @type {LoadingCacheValue} */ (loadingA1CacheValue).promise; + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + strictEqual(results[2].returned, undefined); + + const cacheKeyB = "b"; + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + LoadingContext.Provider, + { value: loading }, + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry(cacheKeyB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + + const { promise: loadingB1Result, resolve: loadingB1ResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + + /** @type {LoadingCacheValue | undefined} */ + let loadingB1CacheValue; + + ReactTestRenderer.act(() => { + loadingB1CacheValue = new LoadingCacheValue( + loading, + cache, + cacheKeyB, + loadingB1Result, + new AbortController(), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + deepStrictEqual(results[4].returned, new Set([loadingB1CacheValue])); + + await ReactTestRenderer.act(async () => { + loadingB1ResultResolve({}); + await /** @type {LoadingCacheValue} */ (loadingB1CacheValue).promise; + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + }); + + it("Initial loading for each cache key used.", async () => { + const loading = new Loading(); + const cache = new Cache(); + const cacheKeyA = "a"; + const { promise: loadingA1Result, resolve: loadingA1ResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + const loadingA1CacheValue = new LoadingCacheValue( + loading, + cache, + cacheKeyA, + loadingA1Result, + new AbortController(), + ); + + /** @type {Array} */ + const results = []; + + const testRenderer = createReactTestRenderer( + React.createElement( + LoadingContext.Provider, + { value: loading }, + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry(cacheKeyA), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("returned" in results[0]); + deepStrictEqual(results[0].returned, new Set([loadingA1CacheValue])); + + const { promise: loadingA2Result, resolve: loadingA2ResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + + /** @type {LoadingCacheValue | undefined} */ + let loadingA2CacheValue; + + ReactTestRenderer.act(() => { + loadingA2CacheValue = new LoadingCacheValue( + loading, + cache, + cacheKeyA, + loadingA2Result, + new AbortController(), + ); + }); + + strictEqual(results.length, 2); + ok("returned" in results[1]); + deepStrictEqual( + results[1].returned, + new Set([loadingA1CacheValue, loadingA2CacheValue]), + ); + + await ReactTestRenderer.act(async () => { + loadingA1ResultResolve({}); + await loadingA1CacheValue.promise; + }); + + strictEqual(results.length, 3); + ok("returned" in results[2]); + deepStrictEqual(results[2].returned, new Set([loadingA2CacheValue])); + + await ReactTestRenderer.act(async () => { + loadingA2ResultResolve({}); + await /** @type {LoadingCacheValue} */ (loadingA2CacheValue).promise; + }); + + strictEqual(results.length, 4); + ok("returned" in results[3]); + strictEqual(results[3].returned, undefined); + + const cacheKeyB = "b"; + const { promise: loadingB1Result, resolve: loadingB1ResultResolve } = + /** @type {Deferred>} */ + (new Deferred()); + + /** @type {LoadingCacheValue | undefined} */ + let loadingB1CacheValue; + + loadingB1CacheValue = new LoadingCacheValue( + loading, + cache, + cacheKeyB, + loadingB1Result, + new AbortController(), + ); + + ReactTestRenderer.act(() => { + testRenderer.update( + React.createElement( + LoadingContext.Provider, + { value: loading }, + React.createElement(ReactHookTest, { + useHook: () => useLoadingEntry(cacheKeyB), + results, + }), + ), + ); + }); + + strictEqual(results.length, 5); + ok("returned" in results[4]); + deepStrictEqual(results[4].returned, new Set([loadingB1CacheValue])); + + await ReactTestRenderer.act(async () => { + loadingB1ResultResolve({}); + await /** @type {LoadingCacheValue} */ (loadingB1CacheValue).promise; + }); + + strictEqual(results.length, 6); + ok("returned" in results[5]); + strictEqual(results[5].returned, undefined); + }); +}); diff --git a/useWaterfallLoad.mjs b/useWaterfallLoad.mjs new file mode 100644 index 0000000..bbe5c2f --- /dev/null +++ b/useWaterfallLoad.mjs @@ -0,0 +1,53 @@ +// @ts-check + +/** + * @import waterfallRender from "react-waterfall-render/waterfallRender.mjs" + * @import { CacheKey } from "./Cache.mjs" + * @import { Loader } from "./types.mjs" + * @import useAutoLoad from "./useAutoLoad.mjs" + */ + +import React from "react"; +import WaterfallRenderContext from "react-waterfall-render/WaterfallRenderContext.mjs"; + +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import useCache from "./useCache.mjs"; + +/** + * React hook to load a {@link Cache.store cache store} entry if the + * {@link WaterfallRenderContext waterfall render context} is populated, i.e. + * when {@link waterfallRender waterfall rendering} for either a server side + * render or to preload components in a browser environment. + * @param {CacheKey} cacheKey Cache key. + * @param {Loader} load Memoized function that starts the loading. + * @returns {boolean} Did loading start. If so, it’s efficient for the component + * to return `null` since this render will be discarded anyway for a re-render + * onces the loading ends. + * @see {@link useAutoLoad `useAutoLoad`}, often used alongside this hook. + */ +export default function useWaterfallLoad(cacheKey, load) { + if (typeof cacheKey !== "string") + throw new TypeError("Argument 1 `cacheKey` must be a string."); + + if (typeof load !== "function") + throw new TypeError("Argument 2 `load` must be a function."); + + const cache = useCache(); + const declareLoading = React.useContext(WaterfallRenderContext); + + if (declareLoading && !(cacheKey in cache.store)) { + // Todo: First, check if already loading? + const loadingCacheValue = load(); + + if (!(loadingCacheValue instanceof LoadingCacheValue)) + throw new TypeError( + "Argument 2 `load` must return a `LoadingCacheValue` instance.", + ); + + declareLoading(loadingCacheValue.promise); + + return true; + } + + return false; +} diff --git a/useWaterfallLoad.test.mjs b/useWaterfallLoad.test.mjs new file mode 100644 index 0000000..d78bcdb --- /dev/null +++ b/useWaterfallLoad.test.mjs @@ -0,0 +1,279 @@ +// @ts-check + +/** + * @import { ReactHookResult } from "./test/ReactHookTest.mjs" + * @import { Loader } from "./types.mjs" + */ + +import "./test/polyfillCustomEvent.mjs"; + +import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert"; +import { describe, it } from "node:test"; + +import React from "react"; +import ReactDOMServer from "react-dom/server"; +import waterfallRender from "react-waterfall-render/waterfallRender.mjs"; + +import Cache from "./Cache.mjs"; +import CacheContext from "./CacheContext.mjs"; +import Loading from "./Loading.mjs"; +import LoadingCacheValue from "./LoadingCacheValue.mjs"; +import assertBundleSize from "./test/assertBundleSize.mjs"; +import createReactTestRenderer from "./test/createReactTestRenderer.mjs"; +import ReactHookTest from "./test/ReactHookTest.mjs"; +import useCacheEntry from "./useCacheEntry.mjs"; +import useWaterfallLoad from "./useWaterfallLoad.mjs"; + +describe("React hook `useWaterfallLoad`.", { concurrency: true }, () => { + /** + * Dummy loader for testing. + * @type {Loader} + */ + const dummyLoader = () => + new LoadingCacheValue( + new Loading(), + new Cache(), + "a", + Promise.resolve(), + new AbortController(), + ); + + it("Bundle size.", async () => { + await assertBundleSize( + new URL("./useWaterfallLoad.mjs", import.meta.url), + 1000, + ); + }); + + it("Argument 1 `cacheKey` not a string.", () => { + throws(() => { + useWaterfallLoad( + // @ts-expect-error Testing invalid. + true, + dummyLoader, + ); + }, new TypeError("Argument 1 `cacheKey` must be a string.")); + }); + + it("Argument 2 `load` not a function.", () => { + throws(() => { + useWaterfallLoad( + "a", + // @ts-expect-error Testing invalid. + true, + ); + }, new TypeError("Argument 2 `load` must be a function.")); + }); + + it("Cache context missing.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement(ReactHookTest, { + useHook: () => useWaterfallLoad("a", dummyLoader), + results, + }), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual(results[0].threw, new TypeError("Cache context missing.")); + }); + + it("Cache context value not a `Cache` instance.", () => { + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { + // @ts-expect-error Testing invalid. + value: true, + }, + React.createElement(ReactHookTest, { + useHook: () => useWaterfallLoad("a", dummyLoader), + results, + }), + ), + ); + + strictEqual(results.length, 1); + ok("threw" in results[0]); + deepStrictEqual( + results[0].threw, + new TypeError("Cache context value must be a `Cache` instance."), + ); + }); + + it("Waterfall render context value undefined.", () => { + const cache = new Cache(); + + let didLoad = false; + + /** @type {Array} */ + const results = []; + + createReactTestRenderer( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(ReactHookTest, { + useHook: () => + useWaterfallLoad("a", () => { + didLoad = true; + + return dummyLoader(); + }), + results, + }), + ), + ); + + strictEqual(didLoad, false); + strictEqual(results.length, 1); + ok("returned" in results[0]); + strictEqual(results[0].returned, false); + }); + + it("Waterfall render context value defined, without initial cache value, invalid `load` return.", async () => { + const cache = new Cache(); + + const TestComponent = () => { + useWaterfallLoad( + "a", + () => + // @ts-expect-error Testing invalid. + true, + ); + + return null; + }; + + await rejects( + waterfallRender( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(TestComponent), + ), + ReactDOMServer.renderToStaticMarkup, + ), + new TypeError( + "Argument 2 `load` must return a `LoadingCacheValue` instance.", + ), + ); + }); + + it("Waterfall render context value defined, without initial cache value, valid `load` return.", async () => { + const cacheKey = "a"; + const cacheValue = "b"; + const cache = new Cache(); + const loading = new Loading(); + + /** @type {Array} */ + const loadCalls = []; + + /** @type {Array} */ + const hookReturns = []; + + /** @type {Loader} */ + function load() { + loadCalls.push(!!arguments.length); + + return new LoadingCacheValue( + loading, + cache, + cacheKey, + Promise.resolve(cacheValue), + new AbortController(), + ); + } + + const TestComponent = () => { + const cacheValue = /** @type {string | undefined} */ ( + useCacheEntry(cacheKey) + ); + + const didLoad = useWaterfallLoad(cacheKey, load); + + hookReturns.push(didLoad); + + return !cacheValue || didLoad + ? null + : React.createElement(React.Fragment, null, cacheValue); + }; + + const html = await waterfallRender( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(TestComponent), + ), + ReactDOMServer.renderToStaticMarkup, + ); + + deepStrictEqual(loadCalls, [false]); + deepStrictEqual(hookReturns, [true, false]); + deepStrictEqual(cache.store, { + [cacheKey]: cacheValue, + }); + strictEqual(html, cacheValue); + }); + + it("Waterfall render context value defined, with initial cache value, valid `load` return.", async () => { + const cacheKey = "a"; + const cacheValue = "b"; + const cache = new Cache({ + [cacheKey]: cacheValue, + }); + const loading = new Loading(); + + /** @type {Array} */ + const loadCalls = []; + + /** @type {Array} */ + const hookReturns = []; + + /** @type {Loader} */ + function load() { + loadCalls.push(!!arguments.length); + + return new LoadingCacheValue( + loading, + cache, + cacheKey, + Promise.resolve("c"), + new AbortController(), + ); + } + + const TestComponent = () => { + const cacheValue = /** @type {string | undefined} */ ( + useCacheEntry(cacheKey) + ); + + const didLoad = useWaterfallLoad(cacheKey, load); + + hookReturns.push(didLoad); + + return !cacheValue || didLoad + ? null + : React.createElement(React.Fragment, null, cacheValue); + }; + + const html = await waterfallRender( + React.createElement( + CacheContext.Provider, + { value: cache }, + React.createElement(TestComponent), + ), + ReactDOMServer.renderToStaticMarkup, + ); + + deepStrictEqual(loadCalls, []); + deepStrictEqual(hookReturns, [false]); + strictEqual(html, cacheValue); + }); +});