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
-[](https://npm.im/graphql-react) [](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 ? (
-
- ) : 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 ? (
->
-> ) : 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) | [](https://packagephobia.now.sh/result?p=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) | [](https://packagephobia.now.sh/result?p=apollo-boost) | [](https://bundlephobia.com/result?p=apollo-boost) |
-| [`@apollo/react-hooks`](https://npm.im/@apollo/react-hooks) | [](https://packagephobia.now.sh/result?p=@apollo/react-hooks) | [](https://bundlephobia.com/result?p=@apollo/react-hooks) |
-| [`graphql`](https://npm.im/graphql) | [](https://packagephobia.now.sh/result?p=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