diff --git a/CHANGELOG.md b/CHANGELOG.md index 013cb0f4365..70010b19dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,9 @@ ``` [@hwillson](https://github.com/hwillson) in [#5357](https://github.com/apollographql/apollo-client/pull/5357) +- React SSR features (previously accessed via `@apollo/react-ssr`) can now be accessed from the separate Apollo Client entry point of `@apollo/client/react/ssr`. These features are not included in the default `@apollo/client` bundle.
+ [@hwillson](https://github.com/hwillson) in [#6499](https://github.com/apollographql/apollo-client/pull/6499) + ### General - **[BREAKING]** Removed `graphql-anywhere` since it's no longer used by Apollo Client.
diff --git a/config/prepareDist.js b/config/prepareDist.js index 2203f951bd8..ab51de26961 100644 --- a/config/prepareDist.js +++ b/config/prepareDist.js @@ -53,11 +53,16 @@ fs.copyFileSync(`${srcDir}/README.md`, `${destDir}/README.md`); fs.copyFileSync(`${srcDir}/LICENSE`, `${destDir}/LICENSE`); -/* @apollo/client/core, @apollo/client/cache, @apollo/client/utilities */ - -function buildPackageJson(bundleName) { +/* + * @apollo/client/core + * @apollo/client/cache + * @apollo/client/utilities + * @apollo/client/react/ssr + */ + +function buildPackageJson(bundleName, entryPoint) { return JSON.stringify({ - name: `@apollo/client/${bundleName}`, + name: `@apollo/client/${entryPoint || bundleName}`, main: `${bundleName}.cjs.js`, module: 'index.js', types: 'index.d.ts', @@ -91,13 +96,14 @@ function writeCjsIndex(bundleName, exportNames, includeNames = true) { ].join('\n')); } -// Create `core`, `cache` and `utilities` bundle package.json files, storing -// them in their associated dist directory. This helps provide a way for the -// Apollo Client core to be used without React (via `@apollo/client/core`), -// and AC's cache and utilities to be used by themselves -// (`@apollo/client/cache` and `@apollo/client/utilities`), via the -// `core.cjs.js`, `cache.cjs.js` and `utilities.cjs.js` CommonJS entry point -// files that only include the exports needed for each bundle. +// Create `core`, `cache`, `utilities` and `ssr` bundle package.json files, +// storing them in their associated dist directory. This helps provide a way +// for the Apollo Client core to be used without React +// (via `@apollo/client/core`), as well as AC's cache, utilities and SSR to be +// used by themselves (`@apollo/client/cache`, `@apollo/client/utilities`, +// `@apollo/client/react/ssr`), via the `core.cjs.js`, `cache.cjs.js`, +// `utilities.cjs.js` and `ssr.cjs.js` CommonJS entry point files that only +// include the exports needed for each bundle. fs.writeFileSync(`${distRoot}/core/package.json`, buildPackageJson('core')); writeCjsIndex('core', loadExportNames('react'), false); @@ -109,3 +115,8 @@ fs.writeFileSync( `${distRoot}/utilities/package.json`, buildPackageJson('utilities') ); + +fs.writeFileSync( + `${distRoot}/react/ssr/package.json`, + buildPackageJson('ssr', 'react/ssr') +); diff --git a/config/rollup.config.js b/config/rollup.config.js index 2d52680aa06..361d5299b55 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -172,6 +172,23 @@ function prepareTesting() { }; } +function prepareReactSSR() { + const ssrDistDir = `${distDir}/react/ssr`; + return { + input: `${ssrDistDir}/index.js`, + external, + output: { + file: `${ssrDistDir}/ssr.cjs.js`, + format: 'cjs', + sourcemap: true, + exports: 'named', + }, + plugins: [ + nodeResolve(), + ], + }; +} + function rollup() { return [ prepareESM(packageJson.module, distDir), @@ -179,6 +196,7 @@ function rollup() { prepareCJSMinified(packageJson.main), prepareUtilities(), prepareTesting(), + prepareReactSSR(), ]; } diff --git a/docs/source/migrating/apollo-client-3-migration.md b/docs/source/migrating/apollo-client-3-migration.md index a54ddf2abea..15e4335bfdd 100644 --- a/docs/source/migrating/apollo-client-3-migration.md +++ b/docs/source/migrating/apollo-client-3-migration.md @@ -61,6 +61,16 @@ import { MockedProvider } from '@apollo/client/testing'; As part of migrating, we recommend removing all `@apollo/react-testing` dependencies. +### @apollo/react-ssr + +React Apollo’s SSR utilities (like `getDataFromTree`, `getMarkupFromTree`, and `renderToStringWithData`) are included in the `@apollo/client` package. Access them via `@apollo/client/react/ssr`: + +```js +import { renderToStringWithData } from '@apollo/client/react/ssr'; +``` + +As part of migrating, we recommend removing all `@apollo/react-ssr` dependencies. + ### react-apollo `react-apollo` v3 is an umbrella package that re-exports the following packages: @@ -72,11 +82,10 @@ As part of migrating, we recommend removing all `@apollo/react-testing` dependen - `@apollo/react-ssr` - `@apollo/react-testing` -Because `@apollo/client` includes functionality from `@apollo/react-common`, `@apollo/react-hooks` and `@apollo/react-testing`, we've released a v4 version of `react-apollo` that includes only the following: +Because `@apollo/client` includes functionality from `@apollo/react-common`, `@apollo/react-hooks`, `@apollo/react-ssr` and `@apollo/react-testing`, we've released a v4 version of `react-apollo` that includes only the following: - `@apollo/react-components` - `@apollo/react-hoc` -- `@apollo/react-ssr` This version re-exports the remainder of React functionality directly from `@apollo/client`, so if you upgrade to `react-apollo` v4 you should still have access to everything you had in v3. That being said, we recommend removing all `react-apollo` dependencies and directly installing whichever `@apollo/react-*` packages you need. diff --git a/src/react/ssr/__tests__/useLazyQuery.test.tsx b/src/react/ssr/__tests__/useLazyQuery.test.tsx new file mode 100644 index 00000000000..8c8565df602 --- /dev/null +++ b/src/react/ssr/__tests__/useLazyQuery.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { DocumentNode } from 'graphql'; +import gql from 'graphql-tag'; +import { mockSingleLink } from '../../../utilities/testing/mocking/mockLink'; +import { ApolloClient } from '../../../ApolloClient'; +import { InMemoryCache } from '../../../cache/inmemory/inMemoryCache'; +import { ApolloProvider } from '../../context/ApolloProvider'; +import { useLazyQuery } from '../../hooks/useLazyQuery'; +import { renderToStringWithData } from '..'; + +describe('useLazyQuery Hook SSR', () => { + const CAR_QUERY: DocumentNode = gql` + query { + cars { + make + model + vin + } + } + `; + + const CAR_RESULT_DATA = { + cars: [ + { + make: 'Audi', + model: 'RS8', + vin: 'DOLLADOLLABILL', + __typename: 'Car' + } + ] + }; + + it('should run query only after calling the lazy mode execute function', () => { + const link = mockSingleLink({ + request: { query: CAR_QUERY }, + result: { data: CAR_RESULT_DATA } + }); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + ssrMode: true + }); + + const Component = () => { + let html = null; + const [execute, { loading, called, data }] = useLazyQuery(CAR_QUERY); + + if (!loading && !called) { + execute(); + } + + if (!loading && called) { + expect(loading).toEqual(false); + expect(data).toEqual(CAR_RESULT_DATA); + html =

{data.cars[0].make}

; + } + + return html; + }; + + const app = ( + + + + ); + + return renderToStringWithData(app).then(markup => { + expect(markup).toMatch(/Audi/); + }); + }); +}); diff --git a/src/react/ssr/__tests__/useQuery.test.tsx b/src/react/ssr/__tests__/useQuery.test.tsx new file mode 100644 index 00000000000..14db259fad8 --- /dev/null +++ b/src/react/ssr/__tests__/useQuery.test.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { DocumentNode } from 'graphql'; +import gql from 'graphql-tag'; +import { MockedProvider } from '../../../utilities/testing/mocking/MockedProvider'; +import { mockSingleLink } from '../../../utilities/testing/mocking/mockLink'; +import { ApolloClient } from '../../../ApolloClient'; +import { InMemoryCache } from '../../../cache/inmemory/inMemoryCache'; +import { ApolloProvider } from '../../context/ApolloProvider'; +import { useQuery } from '../../hooks/useQuery'; +import { render, wait } from '@testing-library/react'; +import { renderToStringWithData } from '..'; + +describe('useQuery Hook SSR', () => { + const CAR_QUERY: DocumentNode = gql` + query { + cars { + make + model + vin + } + } + `; + + const CAR_RESULT_DATA = { + cars: [ + { + make: 'Audi', + model: 'RS8', + vin: 'DOLLADOLLABILL', + __typename: 'Car' + } + ] + }; + + const CAR_MOCKS = [ + { + request: { + query: CAR_QUERY + }, + result: { data: CAR_RESULT_DATA } + } + ]; + + it('should support SSR', () => { + const Component = () => { + const { loading, data } = useQuery(CAR_QUERY); + if (!loading) { + expect(data).toEqual(CAR_RESULT_DATA); + const { make, model, vin } = data.cars[0]; + return ( +
+ {make}, {model}, {vin} +
+ ); + } + return null; + }; + + const app = ( + + + + ); + + return renderToStringWithData(app).then(markup => { + expect(markup).toMatch(/Audi/); + }); + }); + + it('should initialize data as `undefined` when loading', () => { + const Component = () => { + const { data, loading } = useQuery(CAR_QUERY); + if (loading) { + expect(data).toBeUndefined(); + } + return null; + }; + + const app = ( + + + + ); + + return renderToStringWithData(app); + }); + + it('should skip SSR tree rendering if `ssr` option is `false`', async () => { + let renderCount = 0; + const Component = () => { + const { data, loading } = useQuery(CAR_QUERY, { ssr: false }); + renderCount += 1; + + if (!loading) { + const { make } = data.cars[0]; + return
{make}
; + } + return null; + }; + + const app = ( + + + + ); + + return renderToStringWithData(app).then(result => { + expect(renderCount).toBe(1); + expect(result).toEqual(''); + }); + }); + + it( + 'should skip both SSR tree rendering and SSR component rendering if ' + + '`ssr` option is `false` and `ssrMode` is `true`', + async () => { + const link = mockSingleLink({ + request: { query: CAR_QUERY }, + result: { data: CAR_RESULT_DATA } + }); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + ssrMode: true + }); + + let renderCount = 0; + const Component = () => { + const { data, loading } = useQuery(CAR_QUERY, { ssr: false }); + + let content = null; + switch (renderCount) { + case 0: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 1: // FAIL; should not render a second time + default: + } + + renderCount += 1; + return content; + }; + + const app = ( + + + + ); + + await renderToStringWithData(app).then(result => { + expect(renderCount).toBe(1); + expect(result).toEqual(''); + }); + + renderCount = 0; + + render( + + + + ); + + await wait(() => { + expect(renderCount).toBe(1); + }); + } + ); +}); diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts new file mode 100644 index 00000000000..8c5972a9eea --- /dev/null +++ b/src/react/ssr/getDataFromTree.ts @@ -0,0 +1,55 @@ +import React from 'react'; +import { getApolloContext } from '../context/ApolloContext'; +import { RenderPromises } from './RenderPromises'; + +export function getDataFromTree( + tree: React.ReactNode, + context: { [key: string]: any } = {} +) { + return getMarkupFromTree({ + tree, + context, + // If you need to configure this renderFunction, call getMarkupFromTree + // directly instead of getDataFromTree. + renderFunction: require('react-dom/server').renderToStaticMarkup + }); +} + +export type GetMarkupFromTreeOptions = { + tree: React.ReactNode; + context?: { [key: string]: any }; + renderFunction?: (tree: React.ReactElement) => string; +}; + +export function getMarkupFromTree({ + tree, + context = {}, + // The rendering function is configurable! We use renderToStaticMarkup as + // the default, because it's a little less expensive than renderToString, + // and legacy usage of getDataFromTree ignores the return value anyway. + renderFunction = require('react-dom/server').renderToStaticMarkup +}: GetMarkupFromTreeOptions): Promise { + const renderPromises = new RenderPromises(); + + function process(): Promise | string { + // Always re-render from the rootElement, even though it might seem + // better to render the children of the component responsible for the + // promise, because it is not possible to reconstruct the full context + // of the original rendering (including all unknown context provider + // elements) for a subtree of the original component tree. + const ApolloContext = getApolloContext(); + const html = renderFunction( + React.createElement( + ApolloContext.Provider, + { value: { ...context, renderPromises } }, + tree + ) + ); + + return renderPromises.hasPromises() + ? renderPromises.consumeAndAwaitPromises().then(process) + : html; + } + + return Promise.resolve().then(process); +} diff --git a/src/react/ssr/index.ts b/src/react/ssr/index.ts new file mode 100644 index 00000000000..136d3ca7bc7 --- /dev/null +++ b/src/react/ssr/index.ts @@ -0,0 +1,2 @@ +export { getMarkupFromTree, getDataFromTree } from './getDataFromTree'; +export { renderToStringWithData } from './renderToStringWithData'; diff --git a/src/react/ssr/renderToStringWithData.ts b/src/react/ssr/renderToStringWithData.ts new file mode 100644 index 00000000000..a0a8eefc07d --- /dev/null +++ b/src/react/ssr/renderToStringWithData.ts @@ -0,0 +1,11 @@ +import { ReactElement } from 'react'; +import { getMarkupFromTree } from './getDataFromTree'; + +export function renderToStringWithData( + component: ReactElement +): Promise { + return getMarkupFromTree({ + tree: component, + renderFunction: require('react-dom/server').renderToString + }); +}