├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── funding.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── changelog.md ├── cjsDefaultImport.mjs ├── cjsDefaultImport.test.mjs ├── jsconfig.json ├── license.md ├── package.json ├── readme.md ├── test ├── execFilePromise.mjs ├── fixtures │ └── next-project │ │ ├── next.config.mjs │ │ ├── pages │ │ ├── _app.mjs │ │ ├── index.mjs │ │ └── second.mjs │ │ └── polyfills.js ├── listen.mjs └── startNext.mjs ├── withGraphQLReact.mjs └── withGraphQLReact.test.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/fixtures/next-project/out 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:optimal-modules/recommended", 5 | "plugin:react-hooks/recommended" 6 | ], 7 | "env": { 8 | "es2022": true, 9 | "node": true, 10 | "browser": true 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": "latest" 14 | }, 15 | "plugins": ["simple-import-sort"], 16 | "rules": { 17 | "simple-import-sort/imports": "error", 18 | "simple-import-sort/exports": "error" 19 | }, 20 | "overrides": [ 21 | { 22 | "files": ["*.mjs"], 23 | "parserOptions": { 24 | "sourceType": "module" 25 | }, 26 | "globals": { 27 | "__dirname": "off", 28 | "__filename": "off", 29 | "exports": "off", 30 | "module": "off", 31 | "require": "off" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: jaydenseric 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: Test with Node.js v${{ matrix.node }} and ${{ matrix.os }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest] 10 | node: ["18", "20", "21"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup Node.js v${{ matrix.node }} 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{ matrix.node }} 17 | - name: npm install and test 18 | run: npm install-test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .next 4 | test/fixtures/next-project/out 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | .next 3 | test/fixtures/next-project/out 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "never" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.disableAutomaticTypeAcquisition": true, 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # next-graphql-react changelog 2 | 3 | ## 16.0.0 4 | 5 | ### Major 6 | 7 | - Updated Node.js support to `^18.17.0 || >=20.4.0`. 8 | - Updated dev dependencies, some of which require newer Node.js versions than previously supported. 9 | - Use the Node.js test runner API and remove the dev dependency [`test-director`](https://npm.im/test-director). 10 | - Refactored tests to no longer polyfill the standard `AbortController`, `AbortSignal`, `Event`, `EventTarget`, and `performance` APIs available in modern Node.js and removed the dev dependencies [`abort-controller`](https://npm.im/abort-controller) and [`event-target-shim`](https://npm.im/event-target-shim). 11 | 12 | ### Patch 13 | 14 | - Updated the [`next`](https://npm.im/next) peer dependency to `12 - 14`, fixing [#7](https://github.com/jaydenseric/next-graphql-react/issues/7). 15 | - Updated dependencies. 16 | - Updated the `package.json` field `repository` to conform to new npm requirements. 17 | - Integrated the ESLint plugin [`eslint-plugin-optimal-modules`](https://npm.im/eslint-plugin-optimal-modules). 18 | - Updated GitHub Actions CI config: 19 | - The workflow still triggers on push, but no longer on pull request. 20 | - The workflow can now be manually triggered. 21 | - Run tests with Node.js v18, v20, v21. 22 | - Updated `actions/checkout` to v4. 23 | - Updated `actions/setup-node` to v4. 24 | - Improved the types for test fixture Next.js config. 25 | - For the function `withGraphQLReact` tests: 26 | - Temporarily disabled the tests for Node.js v18 due to the Node.js test runner bug [nodejs/node#48845](https://github.com/nodejs/node/issues/48845) that will be fixed in a future Node.js v18 release. 27 | - Use the new Puppeteer headless mode. 28 | - For the client side page load test: 29 | - Attempt to wait until the JS has loaded and the React app has mounted before clicking the navigation link. 30 | - Simulate fast 3G network conditions to ensure GraphQL query loading state can render and be asserted. 31 | - Migrated use of the deprecated Next.js CLI `next export` to the new Next.js static export API. 32 | - Removed an apparently no longer necessary workaround that forced the process to exit after tests; older Next.js used to stay running after closing it’s server. 33 | - Fixed bugs in the test helper function `startNext`. 34 | - Added tests for the internal function `cjsDefaultImport`. 35 | - Updated link URLs in the readme. 36 | 37 | ## 15.0.2 38 | 39 | ### Patch 40 | 41 | - Use a new internal helper function `cjsDefaultImport` to normalize the default import value from the CJS module `next/app.js` that has a `default` property, preserving the type for the various ways TypeScript may be configured. 42 | 43 | ## 15.0.1 44 | 45 | ### Patch 46 | 47 | - Updated the [`next`](https://npm.im/next) peer dependency to `12 - 13`. 48 | - Updated dev dependencies. 49 | - Fixed a link in the v14.0.0 changelog entry. 50 | 51 | ## 15.0.0 52 | 53 | ### Major 54 | 55 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^20.0.0`. 56 | - Updated the [`react-waterfall-render`](https://npm.im/react-waterfall-render) dependency to v5. 57 | 58 | ### Patch 59 | 60 | - Updated dev dependencies. 61 | - Use the `node:` URL scheme for Node.js builtin module imports in tests. 62 | - Migrated from the Node.js builtin module `fs` to `node:fs/promises` in tests. 63 | - Replaced the test helper function `fsPathRemove` with the function `rm` from the Node.js builtin module `node:fs/promises`. 64 | - Tweaked the readme. 65 | 66 | ## 14.0.0 67 | 68 | ### Major 69 | 70 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^19.0.0`. 71 | - Updated the [`react`](https://npm.im/react) and [`react-dom`](https://npm.im/react-dom) peer dependencies to `^18.0.0`. 72 | - Updated `react-dom/server` imports to suit React v18. 73 | 74 | ### Patch 75 | 76 | - Updated dependencies. 77 | - Removed the now redundant `not IE > 0` from the Browserslist query. 78 | - Use the TypeScript type for Next.js config in test fixtures. 79 | - Revamped the readme: 80 | - Removed the badges. 81 | - Better installation instructions that don’t assume the Next.js custom app module has a `.js` file extension. 82 | - Added information about TypeScript config and [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). 83 | 84 | ## 13.0.0 85 | 86 | ### Major 87 | 88 | - Updated Node.js support to `^14.17.0 || ^16.0.0 || >= 18.0.0`. 89 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^18.0.0`. 90 | - Updated dependencies. 91 | - Implemented TypeScript types via JSDoc comments. 92 | 93 | ### Patch 94 | 95 | - Simplified dev dependencies and config for ESLint. 96 | - 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. 97 | - Check TypeScript types via a new package `types` script. 98 | - Support Next.js page response `Link` header array values. 99 | - Use `React.createElement` instead of the [the new React JSX runtime](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) in tests. 100 | - Updated GitHub Actions CI config: 101 | - Run tests with Node.js v14, v16, v18. 102 | - Updated `actions/checkout` to v3. 103 | - Updated `actions/setup-node` to v3. 104 | - Use the `.mjs` file extension for Next.js pages in test fixtures. 105 | - Removed the readme section “Examples”. 106 | - Fixed a readme typo. 107 | - Added a `license.md` MIT License file. 108 | 109 | ## 12.0.0 110 | 111 | ### Major 112 | 113 | - Updated Node.js support to `^12.22.0 || ^14.17.0 || >= 16.0.0`. 114 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^16.0.0`. 115 | - Updated the [`next`](https://npm.im/next) peer dependency to `^12.0.0`. 116 | - Updated dependencies, some of which require newer Node.js versions than previously supported. 117 | - Public modules are now individually listed in the package `files` and `exports` fields. 118 | - Removed `./package` from the package `exports` field; the full `package.json` filename must be used in a `require` path. 119 | - Removed the package main index module; deep imports must be used. 120 | - Shortened public module deep import paths, removing the `/public/`. 121 | - 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). 122 | - 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). 123 | 124 | ### Patch 125 | 126 | - Also run GitHub Actions CI with Node.js v17. 127 | - Removed the redundant [`graphql`](https://npm.im/graphql) dev dependency. 128 | - Simplified package scripts. 129 | - Reorganized the test file structure. 130 | - Workaround Next.js not gracefully closing in tests. 131 | - Removed a redundant prepare step that’s a no-op in current Next.js versions when programmatically starting Next.js in tests. 132 | - Fixed an internal JSDoc type. 133 | - Configured Prettier option `singleQuote` to the default, `false`. 134 | 135 | ## 11.0.0 136 | 137 | ### Major 138 | 139 | - Updated the [`next`](https://npm.im/next) peer dependency to `9.5 - 11`. 140 | - Removed `Head.rewind()` within the function `withGraphQLReact`, as it was made a noop in Next.js v9.5 and was removed in Next.js v11. 141 | 142 | ### Patch 143 | 144 | - Updated dev dependencies. 145 | - Added the [`eslint-config-next`](https://npm.im/eslint-config-next) dev dependency for [`next`](https://npm.im/next) v11. 146 | 147 | ## 10.0.1 148 | 149 | ### Patch 150 | 151 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `14 - 15`. 152 | - Updated dev dependencies. 153 | - Renamed imports in the test index module. 154 | - Use improved static fixtures instead of creating fixtures each test run, removing the [`disposable-directory`](https://npm.im/disposable-directory) and [`install-from`](https://npm.im/install-from) dev dependencies. 155 | - Use the `NEXT_TELEMETRY_DISABLED` environment variable to disable Next.js telemetry for tests. 156 | - Amended the changelog entries for v3.0.1, v3.0.2, v7.0.0, v8.0.1, v9.0.0, and v10.0.0. 157 | - Documentation tweaks. 158 | 159 | ## 10.0.0 160 | 161 | ### Major 162 | 163 | - Updated Node.js support to `^12.20 || >= 14.13`. 164 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^14.0.0`. 165 | - Updated dependencies, some of which require newer Node.js versions than were previously supported. 166 | - 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 `next-graphql-react/public/` must now include the `.js` file extension. 167 | - The tests are now ESM in `.mjs` files instead of CJS in `.js` files. 168 | 169 | ### Minor 170 | 171 | - Added a package `sideEffects` field. 172 | 173 | ### Patch 174 | 175 | - Updated GitHub Actions CI config to run tests with Node.js v12, v14, v16. 176 | - Simplified JSDoc related package scripts now that [`jsdoc-md`](https://npm.im/jsdoc-md) v10+ automatically generates a Prettier formatted readme. 177 | - Added a package `test:jsdoc` script that checks the readme API docs are up to date with the source JSDoc. 178 | - Use the `.js` file extension in internal `require` paths. 179 | - Updated the [example Next.js app](https://graphql-react.vercel.app) URL in the readme. 180 | - Documentation tweaks. 181 | - The file `changelog.md` is no longer published. 182 | 183 | ## 9.0.0 184 | 185 | ### Major 186 | 187 | - Updated Node.js support to `^12.0.0 || >= 13.7.0`. 188 | - Stopped supporting Internet Explorer. 189 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^13.0.0`. 190 | - Updated the [`react`](https://npm.im/react) peer dependency to `16.14 - 17`. 191 | - Use [the new React JSX runtime](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html). 192 | - Reorganized file structure. Deep import paths beginning with `next-graphql-react/universal` must be updated to `next-graphql-react/public`. 193 | - The `withGraphQLApp` higher order function has changed: 194 | - It’s been renamed `withGraphQLReact`. 195 | - It now automatically sets the context required for the new [`graphql-react`](https://npm.im/graphql-react) v13 API. 196 | - It now uses `async`/`await` instead of `Promise` chains. 197 | - The React class component it returns has been refactored to a functional component using React hooks. 198 | - Published modules now contain JSDoc comments, which might affect TypeScript projects. 199 | 200 | ### Minor 201 | 202 | - Allow React component `displayName` to be removed in production builds. 203 | 204 | ### Patch 205 | 206 | - Updated dev dependencies. 207 | - Removed the redundant [`object-assign`](https://npm.im/object-assign) dependency. 208 | - Removed Babel and related dependencies and config. 209 | - Refactored experimental syntax to what is supported for the Browserslist query. 210 | - Restructured tests to mirror the published file structure. 211 | - Updated the package description. 212 | - Updated a Next.js docs link URL. 213 | - Internal JSDoc tweaks. 214 | - Readme edits, including: 215 | - Updated the “Setup” section. 216 | - Updated the “Support” section. 217 | 218 | ## 8.0.4 219 | 220 | ### Patch 221 | 222 | - Updated dependencies. 223 | - Removed redundant dev dependencies. 224 | - Stop using [`hard-rejection`](https://npm.im/hard-rejection) to detect unhandled `Promise` rejections in tests, as Node.js v15+ does this natively. 225 | - Tweaked the v8.0.3 changelog entry. 226 | - Always use regex `u` mode. 227 | - Use the Next.js JS API instead of the CLI to start Next.js in tests, fixing Next.js start detection in tests broken since Next.js v10.0.6-canary.8. 228 | - Asynchronously create test fixture files. 229 | - Fixed incorrect console output indentation following certain test failures. 230 | - Added tests for SSR GraphQL response `Link` header forwarding to the client. 231 | - Fixed errors that can happen during a Next.js build or SSR due to unparsable `Link` headers. 232 | - Internal JSDoc tweaks. 233 | - Updated GitHub Actions CI config: 234 | - Updated `actions/checkout` to v2. 235 | - Updated `actions/setup-node` to v2. 236 | - Don’t specify the `CI` environment variable as it’s set by default. 237 | 238 | ## 8.0.3 239 | 240 | ### Patch 241 | 242 | - Updated the [`next`](https://npm.im/next) peer dependency to `9.0.3 - 10`. 243 | - Updated the [`react`](https://npm.im/react) peer dependency to `16.8 - 17`. 244 | - Updated dependencies. 245 | - Moved [`disposable-directory`](https://npm.im/disposable-directory) to dev dependencies. 246 | - Also run GitHub Actions with Node.js v15. 247 | - Fixed a test hanging in Node.js v15. 248 | 249 | ## 8.0.2 250 | 251 | ### Patch 252 | 253 | - Updated dependencies. 254 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `11 - 12`. 255 | 256 | ## 8.0.1 257 | 258 | ### Patch 259 | 260 | - Updated dependencies. 261 | - Derive fixture dependency versions from dev dependency versions. 262 | - No longer separately build ESM and CJS to simplify package scripts, Babel and ESLint config. 263 | - Use `require` instead of dynamic `import` in `withGraphQLApp` source, as since v7.0.0 the module is only published as CJS. 264 | - Removed unnecessary `.js` file extensions from `require` paths. 265 | - Simplified the GitHub Actions CI config with the [`npm install-test`](https://docs.npmjs.com/cli/v7/commands/npm-install-test) command. 266 | - Clearly documented ways to `import` and `require` the package exports. 267 | - 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. 268 | 269 | ## 8.0.0 270 | 271 | ### Major 272 | 273 | - Updated supported Node.js versions to `^10.17.0 || ^12.0.0 || >= 13.7.0`. 274 | - Added integration tests. These use modern Node.js APIs, increasing the minimum supported Node.js version. 275 | 276 | ### Patch 277 | 278 | - Updated dependencies. 279 | - Stop testing with Node.js v13. 280 | - Added missing file extensions to dynamic imports from Next.js. 281 | - Removed documentation relating to polyfilling `Promise` and `fetch`, as they are automatically polyfilled by recent versions of Next.js. 282 | - Updated the `.editorconfig` file. 283 | 284 | ## 7.0.1 285 | 286 | ### Patch 287 | 288 | - Corrected the package description to match the current API. 289 | - Updated JSDoc code examples: 290 | - Prettier formatting. 291 | - Import React in examples containing JSX. 292 | 293 | ## 7.0.0 294 | 295 | ### Major 296 | 297 | - Added a package [`exports`](https://nodejs.org/api/packages.html#packages_exports) field to support native ESM in Node.js. 298 | - 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). 299 | - Updated Node.js support from v10+ to `10 - 12 || >= 13.7` to reflect the package `exports` related breaking changes. 300 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^11.0.0`. 301 | - Removed `withGraphQLConfig`; `withGraphQLApp` now uses dynamic `import` to only load certain dependencies in a server environment. 302 | 303 | ### Patch 304 | 305 | - Updated dependencies. 306 | - Removed the [`@babel/plugin-proposal-object-rest-spread`](https://npm.im/@babel/plugin-proposal-object-rest-spread) and [`babel-plugin-transform-replace-object-assign`](https://npm.im/babel-plugin-transform-replace-object-assign) dev dependencies and simplified Babel config. 307 | - Improved the package `prepare:prettier` and `test:prettier` scripts. 308 | - Reordered the package `test:eslint` script args for consistency with `test:prettier`. 309 | - Configured Prettier option `semi` to the default, `true`. 310 | - Lint fixes for [`prettier`](https://npm.im/prettier) v2. 311 | - Reorder Babel config fields. 312 | - Ensure GitHub Actions run on pull request. 313 | - Also run GitHub Actions with Node.js v14. 314 | - Support Next.js static HTML export, fixing [#4](https://github.com/jaydenseric/next-graphql-react/issues/4). 315 | 316 | ## 6.0.1 317 | 318 | ### Patch 319 | 320 | - Updated dev dependencies. 321 | - Fixed a bug relating to ESM/CJS interoperability and default imports. 322 | 323 | ## 6.0.0 324 | 325 | ### Major 326 | 327 | - Updated Node.js support from v8.10+ to v10+. 328 | - Updated dev dependencies, some of which now require Node.js v10+. 329 | 330 | ### Patch 331 | 332 | - Updated dependencies. 333 | - Removed the now redundant [`eslint-plugin-import-order-alphabetical`](https://npm.im/eslint-plugin-import-order-alphabetical) dev dependency. 334 | - Stop using [`husky`](https://npm.im/husky) and [`lint-staged`](https://npm.im/lint-staged). 335 | - Use strict mode for scripts. 336 | - Fixed page `getInitialProps` not working when `withGraphQLApp` decorates an app that doesn’t have `getInitialProps`. 337 | 338 | ## 5.1.0 339 | 340 | ### Minor 341 | 342 | - Setup [GitHub Sponsors funding](https://github.com/sponsors/jaydenseric): 343 | - Added `.github/funding.yml` to display a sponsor button in GitHub. 344 | - Added a `package.json` `funding` field to enable npm CLI funding features. 345 | 346 | ### Patch 347 | 348 | - Updated dev dependencies. 349 | 350 | ## 5.0.0 351 | 352 | ### Major 353 | 354 | - Updated Node.js support from v8.5+ to v8.10+, to match what the [`eslint`](https://npm.im/eslint) dev dependency now supports. This is unlikely to be a breaking change for the published package. 355 | 356 | ### Patch 357 | 358 | - Updated dev dependencies. 359 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `8.3.0 - 9`. 360 | - Clarified that Opera Mini isn’t supported in the Browserslist queries and readme “Support” section. 361 | - Use GitHub Actions instead of Travis for CI. 362 | - Removed `package-lock.json` from `.gitignore` and `.prettierignore` as it’s disabled in `.npmrc` anyway. 363 | - Updated the `withGraphQLApp` example code for the current Next.js API, and mark it as JSX instead of JS for syntax highlighting. 364 | - Added a readme “Examples” section. 365 | 366 | ## 4.0.0 367 | 368 | ### Major 369 | 370 | - Updated the [`next`](https://npm.im/next) peer dependency to `^9.0.3`. 371 | - Use the new `AppTree` component available in [`next` v9.0.3](https://github.com/zeit/next.js/releases/tag/v9.0.3) in the `App.getInitialProps` static method `context` argument, with the `ssr` function in `withGraphQLApp`. This allows the use of Next.js React hook based APIs such as `useRouter` that previously had undefined context values in SSR (see [zeit/next.js#6042](https://github.com/zeit/next.js/issues/6042)). 372 | 373 | ### Patch 374 | 375 | - Updated dependencies. 376 | - Cleaner readme “API” section table of contents with “See” and “Examples” headings excluded, thanks to [`jsdoc-md` v3.1.0](https://github.com/jaydenseric/jsdoc-md/releases/tag/v3.1.0). 377 | - Removed a now redundant ESLint disable `no-console` comment in `withGraphQLApp`. 378 | 379 | ## 3.2.0 380 | 381 | ### Minor 382 | 383 | - In addition to `preload`, HTTP `Link` headers from GraphQL responses during SSR with the following `rel` parameters are forwarded in the Next.js page response: 384 | - `dns-prefetch` 385 | - `preconnect` 386 | - `prefetch` 387 | - `modulepreload` 388 | - `prerender` 389 | 390 | ### Patch 391 | 392 | - Updated dev dependencies. 393 | - Minor variable rename for clarity. 394 | 395 | ## 3.1.1 396 | 397 | ### Patch 398 | 399 | - Fixed edge case HTTP `Link` header parsing bugs (e.g. an error when a URL contains `,`) by replacing the `filterLinkHeader` and `mergeLinkHeaders` functions with [`http-link-header`](https://npm.im/http-link-header). 400 | - Fixed `withGraphQLConfig` documentation. 401 | - Documented the HTTP `Link` `rel="preload"` header forwarding behavior of `withGraphQLApp`. 402 | 403 | ## 3.1.0 404 | 405 | ### Minor 406 | 407 | - Updated the [`next`](https://npm.im/next) peer dependency to `7 - 9`. 408 | - HTTP `Link` headers with `rel=preload` from GraphQL responses are now merged with the Next.js page response `Link` header (if present), allowing assets to be preloaded from GraphQL queries. 409 | 410 | ### Patch 411 | 412 | - Updated dependencies. 413 | - New `src/universal/decoys` directory structure for decoy server files. 414 | 415 | ## 3.0.2 416 | 417 | ### Patch 418 | 419 | - Updated dependencies. 420 | - Reduced the size of the published `package.json` by moving dev tool config to files. This also prevents editor extensions such as Prettier and ESLint from detecting config and attempting to operate when opening package files installed in `node_modules`. 421 | - Use `cjs` instead of `commonjs` for the [`@babel/preset-env`](https://npm.im/@babel/preset-env) `modules` option. 422 | - Use `>=` in the Browserslist `node` queries. 423 | 424 | ## 3.0.1 425 | 426 | ### Patch 427 | 428 | - Updated dependencies. 429 | - Updated examples for [`graphql-react`](https://npm.im/graphql-react) v8.2.0. 430 | 431 | ## 3.0.0 432 | 433 | ### Major 434 | 435 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^8.0.0`, and updated the implementation for the new API. 436 | - Updated the [`react`](https://npm.im/react) peer dependency to `^16.8.0`. 437 | 438 | ### Minor 439 | 440 | - Updated the [`next`](https://npm.im/next) peer dependency to `7 - 8`. 441 | 442 | ### Patch 443 | 444 | - Updated dev dependencies. 445 | - Simplified the `prepublishOnly` script. 446 | - Updated docs for the new [`graphql-react`](https://npm.im/graphql-react) API. 447 | 448 | ## 2.0.0 449 | 450 | ### Major 451 | 452 | - Updated the [`graphql-react`](https://npm.im/graphql-react) peer dependency to `^7.0.0`. 453 | - Renamed `withGraphQL` to `withGraphQLApp`. 454 | - Added `withGraphQLConfig`, a Next.js custom config decorator that excludes server only `graphql-react/lib/ssr` imports from the client bundle. 455 | 456 | ### Patch 457 | 458 | - Updated dependencies. 459 | - New package description. 460 | - New project structure so server only and universal modules can have different Babel and Browserslist config. 461 | 462 | ## 1.0.2 463 | 464 | ### Patch 465 | 466 | - Updated dev dependencies. 467 | - Catch and `console.log` GraphQL `preload` render errors. 468 | 469 | ## 1.0.1 470 | 471 | ### Patch 472 | 473 | - Fixed GraphQL cache clearing on route changes. 474 | - Fixed incorrect `getInitialProps` implementation. 475 | - Higher-order component display name better follows [React conventions](https://reactjs.org/docs/higher-order-components#convention-wrap-the-display-name-for-easy-debugging): 476 | - Uppercase first letter. 477 | - Decorated component name falls back to `Component` instead of `Unknown`. 478 | - Renamed `cache` prop to `graphqlCache`. 479 | - Improved JSDoc. 480 | 481 | ## 1.0.0 482 | 483 | Initial release. 484 | -------------------------------------------------------------------------------- /cjsDefaultImport.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Normalize a CJS module default import value that may have a `default` 5 | * property, preserving the type for the various ways TypeScript may be 6 | * configured. 7 | * @template T Imported CJS module type. 8 | * @param {T} value Imported CJS module. 9 | * @returns {T extends { default: any } ? T["default"] : T} Normalized default 10 | * import value. 11 | */ 12 | export default function cjsDefaultImport(value) { 13 | return typeof value === "object" && value && "default" in value 14 | ? /** @type {{ default: any }} */ (value).default 15 | : value; 16 | } 17 | -------------------------------------------------------------------------------- /cjsDefaultImport.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { deepStrictEqual, strictEqual } from "node:assert"; 4 | import { describe, it } from "node:test"; 5 | 6 | import cjsDefaultImport from "./cjsDefaultImport.mjs"; 7 | 8 | describe("Function `cjsDefaultImport`.", { concurrency: true }, () => { 9 | describe("Argument 1 `value`.", { concurrency: true }, () => { 10 | it("Non object.", () => { 11 | const value = false; 12 | 13 | strictEqual(cjsDefaultImport(value), value); 14 | }); 15 | 16 | describe("Object.", { concurrency: true }, () => { 17 | it("Property `default` absent.", () => { 18 | const value = Object.freeze({ a: 1 }); 19 | 20 | deepStrictEqual(cjsDefaultImport(value), value); 21 | }); 22 | 23 | it("Property `default` present.", () => { 24 | const value = Object.freeze({ 25 | default: Object.freeze({ a: 1 }), 26 | }); 27 | 28 | deepStrictEqual(cjsDefaultImport(value), value.default); 29 | }); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "maxNodeModuleJsDepth": 10, 4 | "module": "nodenext", 5 | "noEmit": true, 6 | "strict": true 7 | }, 8 | "typeAcquisition": { 9 | "enable": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright Jayden Seric 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-graphql-react", 3 | "version": "16.0.0", 4 | "description": "A graphql-react integration for Next.js.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jayden Seric", 8 | "email": "me@jaydenseric.com", 9 | "url": "https://jaydenseric.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/jaydenseric/next-graphql-react.git" 14 | }, 15 | "homepage": "https://github.com/jaydenseric/next-graphql-react#readme", 16 | "bugs": "https://github.com/jaydenseric/next-graphql-react/issues", 17 | "funding": "https://github.com/sponsors/jaydenseric", 18 | "keywords": [ 19 | "next", 20 | "graphql", 21 | "react", 22 | "esm", 23 | "mjs" 24 | ], 25 | "files": [ 26 | "cjsDefaultImport.mjs", 27 | "withGraphQLReact.mjs" 28 | ], 29 | "sideEffects": [ 30 | "test/fixtures/next-project/polyfills.js" 31 | ], 32 | "exports": { 33 | "./package.json": "./package.json", 34 | "./withGraphQLReact.mjs": "./withGraphQLReact.mjs" 35 | }, 36 | "engines": { 37 | "node": "^18.17.0 || >=20.4.0" 38 | }, 39 | "browserslist": "Node 18.17 - 19 and Node < 19, Node >= 20.4, > 0.5%, not OperaMini all, not dead", 40 | "peerDependencies": { 41 | "graphql-react": "^20.0.0", 42 | "next": "12 - 14", 43 | "react": "^18.0.0", 44 | "react-dom": "^18.0.0" 45 | }, 46 | "dependencies": { 47 | "http-link-header": "^1.1.1", 48 | "react-waterfall-render": "^5.0.0" 49 | }, 50 | "devDependencies": { 51 | "@types/node": "^20.9.3", 52 | "@types/react": "^18.2.38", 53 | "@types/react-dom": "^18.2.16", 54 | "eslint": "^8.54.0", 55 | "eslint-plugin-optimal-modules": "^1.0.2", 56 | "eslint-plugin-react-hooks": "^4.6.0", 57 | "eslint-plugin-simple-import-sort": "^10.0.0", 58 | "graphql-react": "^20.0.0", 59 | "next": "^14.0.3", 60 | "prettier": "^3.1.0", 61 | "puppeteer": "^21.5.2", 62 | "react": "^18.2.0", 63 | "react-dom": "^18.2.0", 64 | "typescript": "^5.3.2" 65 | }, 66 | "scripts": { 67 | "eslint": "eslint .", 68 | "prettier": "prettier -c .", 69 | "types": "tsc -p jsconfig.json", 70 | "tests": "NEXT_TELEMETRY_DISABLED=1 node --test-reporter=spec --test *.test.mjs", 71 | "test": "npm run eslint && npm run prettier && npm run types && npm run tests", 72 | "prepublishOnly": "npm test" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![graphql-react logo](https://cdn.jsdelivr.net/gh/jaydenseric/graphql-react@0.1.0/graphql-react-logo.svg) 2 | 3 | # next-graphql-react 4 | 5 | A [`graphql-react`](https://npm.im/graphql-react) integration for [Next.js](https://nextjs.org). 6 | 7 | ## Installation 8 | 9 | Within an existing [Next.js](https://nextjs.org) project, to install [`next-graphql-react`](https://npm.im/next-graphql-react) and its [`graphql-react`](https://npm.im/graphql-react) peer dependency with [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), run: 10 | 11 | ```sh 12 | npm install next-graphql-react graphql-react 13 | ``` 14 | 15 | Setup the [Next.js custom `App`](https://nextjs.org/docs/advanced-features/custom-app) module: 16 | 17 | - Polyfill the [required globals](https://github.com/jaydenseric/graphql-react#requirements). 18 | - Decorate the default export with the function [`withGraphQLReact`](./withGraphQLReact.mjs). 19 | 20 | Then [React](https://reactjs.org) hooks imported from [`graphql-react`](https://npm.im/graphql-react) can be used within the [Next.js](https://nextjs.org) project pages and components. 21 | 22 | ## Requirements 23 | 24 | Supported runtime environments: 25 | 26 | - [Node.js](https://nodejs.org) versions `^18.17.0 || >=20.4.0`. 27 | - 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). 28 | 29 | Projects must configure [TypeScript](https://www.typescriptlang.org) to use types from the ECMAScript modules that have a `// @ts-check` comment: 30 | 31 | - [`compilerOptions.allowJs`](https://www.typescriptlang.org/tsconfig#allowJs) should be `true`. 32 | - [`compilerOptions.maxNodeModuleJsDepth`](https://www.typescriptlang.org/tsconfig#maxNodeModuleJsDepth) should be reasonably large, e.g. `10`. 33 | - [`compilerOptions.module`](https://www.typescriptlang.org/tsconfig#module) should be `"node16"` or `"nodenext"`. 34 | 35 | ## Exports 36 | 37 | The [npm](https://npmjs.com) package [`next-graphql-react`](https://npm.im/next-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): 38 | 39 | - [`withGraphQLReact.mjs`](./withGraphQLReact.mjs) 40 | -------------------------------------------------------------------------------- /test/execFilePromise.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { execFile } from "node:child_process"; 4 | import { promisify } from "node:util"; 5 | 6 | export default promisify(execFile); 7 | -------------------------------------------------------------------------------- /test/fixtures/next-project/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @satisfies {import("next").NextConfig} */ 4 | const nextConfig = { 5 | output: /** @type {"export" | undefined} */ ( 6 | process.env.TEST_FIXTURE_NEXT_CONFIG_OUTPUT 7 | ), 8 | pageExtensions: ["mjs"], 9 | }; 10 | 11 | export default nextConfig; 12 | -------------------------------------------------------------------------------- /test/fixtures/next-project/pages/_app.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import "../polyfills.js"; 4 | 5 | import NextApp from "next/app.js"; 6 | import NextLink from "next/link.js"; 7 | import React from "react"; 8 | 9 | import withGraphQLReact from "../../../../withGraphQLReact.mjs"; 10 | 11 | if (typeof CustomEvent === "undefined") throw new Error("polyfill failed"); 12 | 13 | /** 14 | * React component for the Next.js app. 15 | * @param {import("next/app.js").AppProps} props Props. 16 | */ 17 | const App = ({ Component, pageProps = {} }) => 18 | React.createElement( 19 | React.Fragment, 20 | null, 21 | React.createElement( 22 | NextLink.default, 23 | { 24 | href: "/second", 25 | passHref: true, 26 | }, 27 | React.createElement("a", null, "Second"), 28 | ), 29 | React.createElement(Component, pageProps), 30 | ); 31 | 32 | // This is for testing that an original response `Link` header is respected by 33 | // `withGraphQLReact`. 34 | /** @param {import("next/app.js").AppContext} context */ 35 | App.getInitialProps = async (context) => { 36 | if ( 37 | // @ts-ignore This is defined by Next.js. 38 | !process.browser && 39 | // This is SSR for a real request, and not a Next.js static HTML export 40 | // that has a mock a Node.js response. 41 | context.ctx.res?.statusCode && 42 | typeof context.ctx.query.linkHeaderNext === "string" 43 | ) 44 | context.ctx.res.setHeader( 45 | "Link", 46 | 47 | // Todo: Also test setting a header array. 48 | decodeURIComponent(context.ctx.query.linkHeaderNext), 49 | ); 50 | 51 | return NextApp.default.getInitialProps(context); 52 | }; 53 | 54 | export default withGraphQLReact(App); 55 | -------------------------------------------------------------------------------- /test/fixtures/next-project/pages/index.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import useAutoLoad from "graphql-react/useAutoLoad.mjs"; 4 | import useCacheEntry from "graphql-react/useCacheEntry.mjs"; 5 | import useLoadGraphQL from "graphql-react/useLoadGraphQL.mjs"; 6 | import useWaterfallLoad from "graphql-react/useWaterfallLoad.mjs"; 7 | import { useRouter } from "next/router.js"; 8 | import React from "react"; 9 | 10 | const cacheKey = "a"; 11 | const fetchOptions = { 12 | method: "POST", 13 | headers: { 14 | Accept: "application/json", 15 | }, 16 | body: JSON.stringify({ 17 | query: "{ a }", 18 | }), 19 | }; 20 | 21 | /** @typedef {{ a: string }} QueryData */ 22 | 23 | export default function IndexPage() { 24 | const { 25 | query: { linkHeaderGraphql }, 26 | } = useRouter(); 27 | 28 | const cacheValue = 29 | /** 30 | * @type {import("graphql-react/fetchGraphQL.mjs").FetchGraphQLResult 31 | * & { data?: QueryData } | undefined} 32 | */ 33 | (useCacheEntry(cacheKey)); 34 | 35 | let fetchUri = /** @type {string} */ (process.env.NEXT_PUBLIC_GRAPHQL_URL); 36 | 37 | if (typeof linkHeaderGraphql === "string") 38 | fetchUri += `?linkHeader=${encodeURIComponent(linkHeaderGraphql)}`; 39 | 40 | const loadGraphQL = useLoadGraphQL(); 41 | const load = React.useCallback( 42 | () => loadGraphQL(cacheKey, fetchUri, fetchOptions), 43 | [fetchUri, loadGraphQL], 44 | ); 45 | 46 | useAutoLoad(cacheKey, load); 47 | 48 | const isWaterfallLoading = useWaterfallLoad(cacheKey, load); 49 | 50 | return isWaterfallLoading 51 | ? null 52 | : cacheValue?.data 53 | ? React.createElement("div", { id: cacheValue.data.a }) 54 | : cacheValue?.errors 55 | ? "Error!" 56 | : React.createElement("div", { id: "loading" }); 57 | } 58 | -------------------------------------------------------------------------------- /test/fixtures/next-project/pages/second.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import useAutoLoad from "graphql-react/useAutoLoad.mjs"; 4 | import useCacheEntry from "graphql-react/useCacheEntry.mjs"; 5 | import useLoadGraphQL from "graphql-react/useLoadGraphQL.mjs"; 6 | import useWaterfallLoad from "graphql-react/useWaterfallLoad.mjs"; 7 | import React from "react"; 8 | 9 | const cacheKey = "b"; 10 | const fetchUri = /** @type {string} */ (process.env.NEXT_PUBLIC_GRAPHQL_URL); 11 | const fetchOptions = { 12 | method: "POST", 13 | headers: { 14 | Accept: "application/json", 15 | }, 16 | body: JSON.stringify({ 17 | query: "{ b }", 18 | }), 19 | }; 20 | 21 | /** @typedef {{ b: string }} QueryData */ 22 | 23 | export default function SecondPage() { 24 | const cacheValue = 25 | /** 26 | * @type {import("graphql-react/fetchGraphQL.mjs").FetchGraphQLResult 27 | * & { data?: QueryData } | undefined} 28 | */ 29 | (useCacheEntry(cacheKey)); 30 | const loadGraphQL = useLoadGraphQL(); 31 | const load = React.useCallback( 32 | () => loadGraphQL(cacheKey, fetchUri, fetchOptions), 33 | [loadGraphQL], 34 | ); 35 | 36 | useAutoLoad(cacheKey, load); 37 | 38 | const isWaterfallLoading = useWaterfallLoad(cacheKey, load); 39 | 40 | return isWaterfallLoading 41 | ? null 42 | : cacheValue?.data 43 | ? React.createElement("div", { id: cacheValue.data.b }) 44 | : cacheValue?.errors 45 | ? "Error!" 46 | : React.createElement("div", { id: "loading" }); 47 | } 48 | -------------------------------------------------------------------------------- /test/fixtures/next-project/polyfills.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | "use strict"; 4 | 5 | if (typeof window === "undefined") { 6 | if (!("CustomEvent" in globalThis)) 7 | // @ts-ignore This isn’t a perfect polyfill. 8 | globalThis.CustomEvent = 9 | /** 10 | * @template [T=unknown] 11 | * @type {globalThis.CustomEvent} 12 | */ 13 | class CustomEvent extends Event { 14 | /** 15 | * @param {string} eventName 16 | * @param {CustomEventInit} [options] 17 | */ 18 | constructor(eventName, options = {}) { 19 | // Workaround a TypeScript bug: 20 | // https://github.com/microsoft/TypeScript/issues/50286 21 | const { detail, ...eventOptions } = options; 22 | super(eventName, eventOptions); 23 | if (detail) this.detail = detail; 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /test/listen.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Starts a Node.js HTTP server. 5 | * @param {import("node:http").Server} server Node.js HTTP server. 6 | * @returns Resolves the port the server is listening on, and a server close 7 | * function. 8 | */ 9 | export default async function listen(server) { 10 | await new Promise((resolve) => { 11 | server.listen(resolve); 12 | }); 13 | 14 | return { 15 | port: /** @type {import("node:net").AddressInfo} */ (server.address()).port, 16 | close: () => server.close(), 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /test/startNext.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { createServer } from "node:http"; 4 | 5 | import next from "next"; 6 | 7 | import cjsDefaultImport from "../cjsDefaultImport.mjs"; 8 | import listen from "./listen.mjs"; 9 | 10 | // Workaround broken Next.js types. 11 | const nextCreateServer = cjsDefaultImport(next); 12 | 13 | /** 14 | * Starts Next.js. 15 | * @param {string} dir Next.js project directory path. 16 | * @returns Resolves the port the server is listening on, and a function to 17 | * close the server. 18 | */ 19 | export default async function startNext(dir) { 20 | const nextServer = nextCreateServer({ dir }); 21 | const nextRequestHandler = nextServer.getRequestHandler(); 22 | 23 | await nextServer.prepare(); 24 | 25 | const server = createServer(nextRequestHandler); 26 | 27 | return await listen(server); 28 | } 29 | -------------------------------------------------------------------------------- /withGraphQLReact.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import Cache from "graphql-react/Cache.mjs"; 4 | import Provider from "graphql-react/Provider.mjs"; 5 | import nextApp from "next/app.js"; 6 | import React from "react"; 7 | 8 | import cjsDefaultImport from "./cjsDefaultImport.mjs"; 9 | 10 | const NextApp = cjsDefaultImport(nextApp); 11 | 12 | /** 13 | * Link `rel` types that make sense to forward from loading responses during SSR 14 | * in the [Next.js](https://nextjs.org) page response. 15 | * @type {Array} 16 | * @see [HTML Living Standard link types](https://html.spec.whatwg.org/dev/links.html#linkTypes). 17 | */ 18 | const FORWARDABLE_LINK_REL = [ 19 | "dns-prefetch", 20 | "preconnect", 21 | "prefetch", 22 | "preload", 23 | "modulepreload", 24 | "prerender", 25 | ]; 26 | 27 | /** 28 | * A [Next.js](https://nextjs.org) custom `App` [React](https://reactjs.org) 29 | * component decorator that returns a higher-order [React](https://reactjs.org) 30 | * component that enables the [`graphql-react`](https://npm.im/graphql-react) 31 | * [React](https://reactjs.org) hooks within children for loading and caching 32 | * data that can be server side rendered and hydrated on the client. 33 | * 34 | * After 35 | * [waterfall rendering](https://github.com/jaydenseric/react-waterfall-render) 36 | * for a server side render, cache values are scanned for a `response` property 37 | * (which should be non-enumerable so it won’t be included in the serialized 38 | * JSON sent to the client for hydration) that is a 39 | * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) 40 | * instance. Any of the following HTTP 41 | * [`Link`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) 42 | * headers found in the responses are deduped and forwarded to the client in the 43 | * [Next.js](https://nextjs.org) page response: 44 | * 45 | * - [`dns-prefetch`](https://html.spec.whatwg.org/dev/links.html#link-type-dns-prefetch) 46 | * - [`preconnect`](https://html.spec.whatwg.org/dev/links.html#link-type-preconnect) 47 | * - [`prefetch`](https://html.spec.whatwg.org/dev/links.html#link-type-prefetch) 48 | * - [`preload`](https://html.spec.whatwg.org/dev/links.html#link-type-preload) 49 | * - [`modulepreload`](https://html.spec.whatwg.org/dev/links.html#link-type-modulepreload) 50 | * - [`prerender`](https://html.spec.whatwg.org/dev/links.html#link-type-prerender) 51 | * 52 | * Link URLs are forwarded unmodified, so avoid sending relative URLs from a 53 | * [GraphQL](https://graphql.org) server hosted on a different domain to the 54 | * app. 55 | * @param {import("react").ComponentType & { 56 | * getInitialProps?: ({ 57 | * Component, 58 | * ctx, 59 | * }: import("next/app.js").AppContext) => 60 | * | import("next/app.js").AppInitialProps 61 | * | Promise 62 | * }} App [Next.js](https://nextjs.org) custom `App` 63 | * [React](https://reactjs.org) component. 64 | * @returns [Next.js](https://nextjs.org) custom `App` higher-order 65 | * [React](https://reactjs.org) component. 66 | * @see [Next.js custom `App` docs](https://nextjs.org/docs/advanced-features/custom-app). 67 | * @see [React higher-order component docs](https://reactjs.org/docs/higher-order-components). 68 | * @example 69 | * A [Next.js](https://nextjs.org) custom `App` in `pages/_app.js`: 70 | * 71 | * ```js 72 | * import withGraphQLReact from "next-graphql-react/withGraphQLReact.mjs"; 73 | * import App from "next/app"; 74 | * 75 | * export default withGraphQLReact(App); 76 | * ``` 77 | */ 78 | export default function withGraphQLReact(App) { 79 | /** 80 | * [Next.js](https://nextjs.org) custom `App` higher-order 81 | * [React](https://reactjs.org) component. 82 | * @param {import("next/app.js").AppProps & { 83 | * cache?: Cache, 84 | * initialCacheStore?: import("graphql-react/Cache.mjs").CacheStore 85 | * }} props Props. 86 | */ 87 | function WithGraphQLReact({ cache, initialCacheStore, ...appProps }) { 88 | const cacheRef = React.useRef(/** @type {Cache | null} */ (null)); 89 | 90 | // This avoids re-creating the React ref initial value, see: 91 | // https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily 92 | if (!cacheRef.current) 93 | cacheRef.current = cache || new Cache(initialCacheStore); 94 | 95 | return React.createElement( 96 | Provider, 97 | { cache: cacheRef.current }, 98 | React.createElement(App, appProps), 99 | ); 100 | } 101 | 102 | // Next.js webpack config uses `process.browser` to eliminate code from the 103 | // relevant server/browser bundles. `typeof window === "undefined"` can’t be 104 | // used, because Next.js implements that using Babel, which doesn’t run on 105 | // `node_modules`. 106 | if ( 107 | // @ts-ignore This is defined by Next.js. 108 | !process.browser 109 | ) 110 | // The following code should be eliminated from client bundles. 111 | 112 | /** 113 | * Gets the initial props. 114 | * @param {import("next/app.js").AppContext} context App context. 115 | * @returns {Promise< 116 | * import("next/app.js").AppInitialProps & 117 | * { initialCacheStore: import("graphql-react/Cache.mjs").CacheStore } 118 | * >} Initial props. 119 | */ 120 | WithGraphQLReact.getInitialProps = async (context) => { 121 | const [ 122 | appProps, 123 | { default: ReactDOMServer }, 124 | { default: waterfallRender }, 125 | ] = await Promise.all([ 126 | App.getInitialProps 127 | ? App.getInitialProps(context) 128 | : NextApp.getInitialProps(context), 129 | import("react-dom/server"), 130 | import("react-waterfall-render/waterfallRender.mjs"), 131 | ]); 132 | 133 | const cache = new Cache(); 134 | 135 | try { 136 | await waterfallRender( 137 | React.createElement(context.AppTree, { cache, ...appProps }), 138 | ReactDOMServer.renderToStaticMarkup, 139 | ); 140 | } catch (error) { 141 | console.error(error); 142 | } 143 | 144 | // Check this is a real, dynamic request and not a Next.js static HTML 145 | // export, either for the static HTML error pages generated by running 146 | // `next build` or for the whole project exported to static HTML by 147 | // running `next export`. Although Next.js docs say for a static HTML 148 | // export `ctx.res` will be an empty object, actually Next.js mocks some 149 | // of the header related properties. Because `statusCode` is never mocked, 150 | // its presence is used to detect if the request is real. See: 151 | // https://nextjs.org/docs/advanced-features/static-html-export#caveats 152 | if ( 153 | // This is just for TypeScript; it should always be true due to the 154 | // surrounding `process.browser` check. 155 | context.ctx.res && 156 | "statusCode" in context.ctx.res 157 | ) { 158 | const { default: LinkHeader } = await import("http-link-header"); 159 | 160 | // This will hold all the `Link` headers parsed from loaded cache value 161 | // responses. 162 | const linkHeaderPlanLoadingResponses = new LinkHeader(); 163 | 164 | for (const cacheValue of Object.values(cache.store)) 165 | if ( 166 | // Potentially any type of data could be cached, not just objects 167 | // for fetched GraphQL. 168 | typeof cacheValue === "object" && 169 | // Not null. 170 | cacheValue && 171 | // The `fetch` API `Response` global should be polyfilled for 172 | // Node.js if there are cache values derived from `fetch` `Response` 173 | // instances. In case there are not, guard against the global being 174 | // undefined. 175 | typeof Response === "function" && 176 | // As a convention, any cache value that’s derived from a `fetch` 177 | // `Response` instance should attach it as a non-enumerable property 178 | // to the cache value object so it can be inspected, but won’t 179 | // serialize to JSON when the cache store is exported for hydration 180 | // on the client after SSR. 181 | /** @type {{ [key: string]: unknown }} */ (cacheValue) 182 | .response instanceof Response 183 | ) { 184 | const linkHeader = /** @type {{ [key: string]: unknown }} */ ( 185 | cacheValue 186 | ).response.headers.get("Link"); 187 | if (linkHeader) 188 | try { 189 | linkHeaderPlanLoadingResponses.parse(linkHeader); 190 | } catch (error) { 191 | // Ignore a parse error. 192 | } 193 | } 194 | 195 | const linkHeaderPlanForwardable = new LinkHeader(); 196 | 197 | linkHeaderPlanLoadingResponses.refs.forEach((link) => { 198 | if ( 199 | // The link has a forwardable `rel`. 200 | FORWARDABLE_LINK_REL.includes(link.rel) && 201 | // A similar link isn’t already set. 202 | !linkHeaderPlanForwardable.refs.some( 203 | ({ uri, rel }) => uri === link.uri && rel === link.rel, 204 | ) 205 | ) 206 | linkHeaderPlanForwardable.set(link); 207 | }); 208 | 209 | if (linkHeaderPlanForwardable.refs.length) { 210 | let linkHeaderPlanResponseFinal = linkHeaderPlanForwardable; 211 | 212 | const linkHeaderResponseOriginal = context.ctx.res.getHeader("Link"); 213 | 214 | if (linkHeaderResponseOriginal) { 215 | // Normalize the original header into an array, as it could have 216 | // been set as either a string or an array of strings. 217 | const linkHeaderResponseOriginalArray = Array.isArray( 218 | linkHeaderResponseOriginal, 219 | ) 220 | ? linkHeaderResponseOriginal 221 | : [linkHeaderResponseOriginal]; 222 | 223 | // The Node.js response `setHeader` API doesn’t do any input 224 | // validation, so project code using this API could have set any 225 | // type of unparsable value for the original response `Link` header. 226 | // If it’s parsable, merge in the forwardable links from the GraphQL 227 | // responses to create the final response `Link` header. 228 | 229 | const linkHeaderPlanResponseOriginal = new LinkHeader(); 230 | 231 | for (const linkHeaderResponseOriginal of linkHeaderResponseOriginalArray) { 232 | try { 233 | linkHeaderPlanResponseOriginal.parse( 234 | linkHeaderResponseOriginal, 235 | ); 236 | } catch (error) { 237 | // Ignore a parse error. It’s ok to exclude the original 238 | // unparsable `Link` header in the final response. 239 | } 240 | } 241 | 242 | if (linkHeaderPlanResponseOriginal.refs.length) { 243 | linkHeaderPlanResponseFinal = linkHeaderPlanResponseOriginal; 244 | 245 | linkHeaderPlanForwardable.refs.forEach((link) => { 246 | if ( 247 | // A similar link isn’t already set. 248 | !linkHeaderPlanResponseFinal.refs.some( 249 | ({ uri, rel }) => uri === link.uri && rel === link.rel, 250 | ) 251 | ) 252 | linkHeaderPlanResponseFinal.set(link); 253 | }); 254 | } 255 | } 256 | 257 | context.ctx.res.setHeader( 258 | "Link", 259 | linkHeaderPlanResponseFinal.toString(), 260 | ); 261 | } 262 | } 263 | 264 | return { 265 | ...appProps, 266 | initialCacheStore: cache.store, 267 | }; 268 | }; 269 | 270 | if (typeof process === "object" && process.env.NODE_ENV !== "production") 271 | /** 272 | * The display name. 273 | * @type {string} 274 | * @see [React display name conventions](https://reactjs.org/docs/higher-order-components.html#convention-wrap-the-display-name-for-easy-debugging). 275 | */ 276 | WithGraphQLReact.displayName = `WithGraphQLReact(${ 277 | App.displayName || App.name || "Component" 278 | })`; 279 | 280 | return WithGraphQLReact; 281 | } 282 | -------------------------------------------------------------------------------- /withGraphQLReact.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { ok, strictEqual } from "node:assert"; 4 | import { readFile, rm } from "node:fs/promises"; 5 | import { createServer } from "node:http"; 6 | import { after, describe, it } from "node:test"; 7 | import { fileURLToPath } from "node:url"; 8 | 9 | import puppeteer, { PredefinedNetworkConditions } from "puppeteer"; 10 | 11 | import execFilePromise from "./test/execFilePromise.mjs"; 12 | import listen from "./test/listen.mjs"; 13 | import startNext from "./test/startNext.mjs"; 14 | 15 | // TODO: Re-enable these tests for Node.js v18 once the fix for this Node.js 16 | // test runner bug is published in a v18 release: 17 | // https://github.com/nodejs/node/issues/48845 The Node.js v18.19.0 release is 18 | // scheduled for 2023-11-28: https://github.com/nodejs/Release/issues/737 19 | if (!process.version.startsWith("v18.")) 20 | describe("Function `withGraphQLReact`.", { concurrency: true }, async () => { 21 | const markerA = "MARKER_A"; 22 | const markerB = "MARKER_B"; 23 | 24 | /** 25 | * Dummy GraphQL server with a hardcoded response. The URL query string 26 | * parameter `linkHeader` can be used to set an arbitrary `Link` header in 27 | * the response. 28 | */ 29 | const graphqlSever = createServer((request, response) => { 30 | /** @type {{ [key: string]: string }} */ 31 | const responseHeaders = { 32 | "Access-Control-Allow-Origin": "*", 33 | "Access-Control-Allow-Headers": 34 | "Origin, X-Requested-With, Content-Type, Accept", 35 | "Content-Type": "application/json", 36 | }; 37 | 38 | const { searchParams } = new URL( 39 | /** @type {string} */ (request.url), 40 | `http://${request.headers.host}`, 41 | ); 42 | 43 | const linkHeader = searchParams.get("linkHeader"); 44 | 45 | if (linkHeader) responseHeaders.Link = linkHeader; 46 | 47 | response.writeHead(200, responseHeaders); 48 | response.write( 49 | JSON.stringify({ 50 | data: { 51 | a: markerA, 52 | b: markerB, 53 | }, 54 | }), 55 | ); 56 | response.end(); 57 | }); 58 | 59 | const { port: portGraphqlSever, close: closeGraphqlSever } = 60 | await listen(graphqlSever); 61 | 62 | after(() => { 63 | closeGraphqlSever(); 64 | }); 65 | 66 | process.env.NEXT_PUBLIC_GRAPHQL_URL = `http://localhost:${portGraphqlSever}`; 67 | 68 | /** Test fixture Next.js project directory URL. */ 69 | const nextProjectUrl = new URL( 70 | "./test/fixtures/next-project/", 71 | import.meta.url, 72 | ); 73 | 74 | describe("Served.", { concurrency: true }, async () => { 75 | const nextBuildOutput = await execFilePromise("npx", ["next", "build"], { 76 | cwd: nextProjectUrl, 77 | }); 78 | 79 | after(async () => { 80 | // Cleanup the Next.js build artifacts. 81 | await rm(new URL(".next", nextProjectUrl), { 82 | force: true, 83 | recursive: true, 84 | }); 85 | }); 86 | 87 | ok(nextBuildOutput.stdout.includes("Compiled successfully")); 88 | 89 | const { port: portNext, close: closeNext } = await startNext( 90 | fileURLToPath(nextProjectUrl), 91 | ); 92 | 93 | after(() => { 94 | closeNext(); 95 | }); 96 | 97 | const browser = await puppeteer.launch({ 98 | headless: "new", 99 | }); 100 | 101 | after(async () => { 102 | await browser.close(); 103 | }); 104 | 105 | const nextServerUrl = `http://localhost:${portNext}`; 106 | 107 | describe("Server side page loads.", { concurrency: true }, () => { 108 | const linkHeaderGraphqlForwardable = 109 | "; rel=dns-prefetch, ; rel=preconnect, ; rel=prefetch, ; rel=preload, ; rel=modulepreload, ; rel=prerender"; 110 | const linkHeaderGraphQLUnforwardable = 111 | "; rel=nonsense"; 112 | 113 | it("Next.js original response `Link` header absent, GraphQL response `Link` header absent.", async () => { 114 | const page = await browser.newPage(); 115 | 116 | try { 117 | const response = await page.goto(nextServerUrl); 118 | 119 | ok(response); 120 | ok(response.ok()); 121 | strictEqual(response.headers().link, undefined); 122 | ok(await page.$(`#${markerA}`)); 123 | } finally { 124 | await page.close(); 125 | } 126 | }); 127 | 128 | it("Next.js original response `Link` header absent, GraphQL response `Link` header parsable.", async () => { 129 | const page = await browser.newPage(); 130 | 131 | try { 132 | const response = await page.goto( 133 | `${nextServerUrl}?linkHeaderGraphql=${encodeURIComponent( 134 | `${linkHeaderGraphqlForwardable}, ${linkHeaderGraphQLUnforwardable}`, 135 | )}`, 136 | ); 137 | 138 | ok(response); 139 | ok(response.ok()); 140 | strictEqual(response.headers().link, linkHeaderGraphqlForwardable); 141 | ok(await page.$(`#${markerA}`)); 142 | } finally { 143 | await page.close(); 144 | } 145 | }); 146 | 147 | it("Next.js original response `Link` header absent, GraphQL response `Link` header unparsable.", async () => { 148 | const page = await browser.newPage(); 149 | 150 | try { 151 | const response = await page.goto( 152 | `${nextServerUrl}?linkHeaderGraphql=.`, 153 | ); 154 | 155 | ok(response); 156 | ok(response.ok()); 157 | strictEqual(response.headers().link, undefined); 158 | ok(await page.$(`#${markerA}`)); 159 | } finally { 160 | await page.close(); 161 | } 162 | }); 163 | 164 | it("Next.js original response `Link` header parsable, GraphQL response `Link` header absent.", async () => { 165 | const page = await browser.newPage(); 166 | 167 | try { 168 | const linkHeaderNext = 169 | "; rel=preconnect, ; rel=nonsense"; 170 | const response = await page.goto( 171 | `${nextServerUrl}?linkHeaderNext=${encodeURIComponent( 172 | linkHeaderNext, 173 | )}`, 174 | ); 175 | 176 | ok(response); 177 | ok(response.ok()); 178 | strictEqual(response.headers().link, linkHeaderNext); 179 | ok(await page.$(`#${markerA}`)); 180 | } finally { 181 | await page.close(); 182 | } 183 | }); 184 | 185 | it("Next.js original response `Link` header parsable, GraphQL response `Link` header parsable, different.", async () => { 186 | const page = await browser.newPage(); 187 | 188 | try { 189 | const linkHeaderNext = 190 | "; rel=preconnect, ; rel=nonsense"; 191 | const response = await page.goto( 192 | `${nextServerUrl}?linkHeaderNext=${encodeURIComponent( 193 | linkHeaderNext, 194 | )}&linkHeaderGraphql=${encodeURIComponent( 195 | `${linkHeaderGraphqlForwardable}, ${linkHeaderGraphQLUnforwardable}`, 196 | )}`, 197 | ); 198 | 199 | ok(response); 200 | ok(response.ok()); 201 | strictEqual( 202 | response.headers().link, 203 | `${linkHeaderNext}, ${linkHeaderGraphqlForwardable}`, 204 | ); 205 | ok(await page.$(`#${markerA}`)); 206 | } finally { 207 | await page.close(); 208 | } 209 | }); 210 | 211 | it("Next.js original response `Link` header parsable, GraphQL response `Link` header parsable, similar.", async () => { 212 | const page = await browser.newPage(); 213 | 214 | try { 215 | const linkHeader = 216 | "; rel=preconnect, ; rel=nonsense"; 217 | const linkHeaderEncoded = encodeURIComponent(linkHeader); 218 | const response = await page.goto( 219 | `${nextServerUrl}?linkHeaderNext=${linkHeaderEncoded}&linkHeaderGraphql=${linkHeaderEncoded}`, 220 | ); 221 | 222 | ok(response); 223 | ok(response.ok()); 224 | strictEqual(response.headers().link, linkHeader); 225 | ok(await page.$(`#${markerA}`)); 226 | } finally { 227 | await page.close(); 228 | } 229 | }); 230 | 231 | it("Next.js original response `Link` header parsable, GraphQL response `Link` header unparsable.", async () => { 232 | const page = await browser.newPage(); 233 | 234 | try { 235 | const linkHeaderNext = 236 | "; rel=preconnect, ; rel=nonsense"; 237 | const response = await page.goto( 238 | `${nextServerUrl}?linkHeaderNext=${encodeURIComponent( 239 | linkHeaderNext, 240 | )}&linkHeaderGraphql=.`, 241 | ); 242 | 243 | ok(response); 244 | ok(response.ok()); 245 | strictEqual(response.headers().link, linkHeaderNext); 246 | ok(await page.$(`#${markerA}`)); 247 | } finally { 248 | await page.close(); 249 | } 250 | }); 251 | 252 | it("Next.js original response `Link` header unparsable, GraphQL response `Link` header absent.", async () => { 253 | const page = await browser.newPage(); 254 | 255 | try { 256 | const linkHeaderNext = "."; 257 | const response = await page.goto( 258 | `${nextServerUrl}?linkHeaderNext=${encodeURIComponent( 259 | linkHeaderNext, 260 | )}`, 261 | ); 262 | 263 | ok(response); 264 | ok(response.ok()); 265 | strictEqual(response.headers().link, linkHeaderNext); 266 | ok(await page.$(`#${markerA}`)); 267 | } finally { 268 | await page.close(); 269 | } 270 | }); 271 | 272 | it("Next.js original response `Link` header unparsable, GraphQL response `Link` header parsable.", async () => { 273 | const page = await browser.newPage(); 274 | 275 | try { 276 | const response = await page.goto( 277 | `${nextServerUrl}?linkHeaderNext=.&linkHeaderGraphql=${encodeURIComponent( 278 | `${linkHeaderGraphqlForwardable}, ${linkHeaderGraphQLUnforwardable}`, 279 | )}`, 280 | ); 281 | 282 | ok(response); 283 | ok(response.ok()); 284 | strictEqual(response.headers().link, linkHeaderGraphqlForwardable); 285 | ok(await page.$(`#${markerA}`)); 286 | } finally { 287 | await page.close(); 288 | } 289 | }); 290 | 291 | it("Next.js original response `Link` header unparsable, GraphQL response `Link` header unparsable.", async () => { 292 | const page = await browser.newPage(); 293 | 294 | try { 295 | const response = await page.goto( 296 | // The unparsable values have to be different so the can be 297 | // separately identified in the final response. 298 | `${nextServerUrl}?linkHeaderNext=.&linkHeaderGraphql=-`, 299 | ); 300 | 301 | ok(response); 302 | ok(response.ok()); 303 | strictEqual( 304 | response.headers().link, 305 | // Because there wasn’t a parsable `Link` header to forward from 306 | // the GraphQL response, the unparsable original Next.js one 307 | // shouldn’t have been replaced in the final response. 308 | ".", 309 | ); 310 | ok(await page.$(`#${markerA}`)); 311 | } finally { 312 | await page.close(); 313 | } 314 | }); 315 | }); 316 | 317 | it("Client side page load.", async () => { 318 | const page = await browser.newPage(); 319 | 320 | try { 321 | const response = await page.goto(nextServerUrl, { 322 | // Wait until the JS has loaded and the React app has mounted. 323 | waitUntil: "networkidle0", 324 | }); 325 | 326 | ok(response); 327 | ok(response.ok()); 328 | 329 | // Simulate fast 3G network conditions for just this headless browser 330 | // page, so when the second page is navigated to client side, the 331 | // page’s GraphQL query loading state can render and be asserted. 332 | await page.emulateNetworkConditions( 333 | PredefinedNetworkConditions["Fast 3G"], 334 | ); 335 | 336 | await Promise.all([ 337 | page.click('[href="/second"]'), 338 | page.waitForNavigation(), 339 | page.waitForSelector("#loading", { timeout: 10000 }), 340 | page.waitForSelector(`#${markerB}`, { timeout: 20000 }), 341 | ]); 342 | } finally { 343 | await page.close(); 344 | } 345 | }); 346 | }); 347 | 348 | it("Static HTML export.", async () => { 349 | /** Next.js static export directory URL. */ 350 | const nextExportUrl = new URL("out/", nextProjectUrl); 351 | 352 | const nextBuildOutput = await execFilePromise("npx", ["next", "build"], { 353 | cwd: nextProjectUrl, 354 | env: { 355 | ...process.env, 356 | TEST_FIXTURE_NEXT_CONFIG_OUTPUT: "export", 357 | }, 358 | }); 359 | 360 | after(async () => { 361 | // Cleanup the Next.js build artifacts. 362 | await rm(nextExportUrl, { 363 | force: true, 364 | recursive: true, 365 | }); 366 | }); 367 | 368 | ok(nextBuildOutput.stdout.includes("Compiled successfully")); 369 | 370 | const html = await readFile(new URL(`index.html`, nextExportUrl), "utf8"); 371 | 372 | ok(html.includes(`id="${markerA}"`)); 373 | }); 374 | }); 375 | --------------------------------------------------------------------------------