From bbe91391242cc2ffa44fa8fa17cd7c73a8c3f3bc Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 13:53:22 -0400 Subject: [PATCH 01/13] feat(core): support Apollo Client 4 --- package-lock.json | 4 +- packages/blocks/package.json | 2 +- packages/faustwp-core/package.json | 2 +- .../src/apollo/errorLoggingLink.ts | 34 +++++----- packages/faustwp-core/src/client.ts | 20 ++++-- .../src/components/FaustProvider.tsx | 3 +- .../src/components/Toolbar/Toolbar.tsx | 6 +- .../components/Toolbar/nodes/MyAccount.tsx | 6 +- .../src/components/WordPressTemplate.tsx | 6 +- .../faustwp-core/src/getWordPressProps.tsx | 6 +- packages/faustwp-core/src/hooks/useAuth.tsx | 2 +- packages/faustwp-core/src/hooks/useLogin.tsx | 4 +- .../faustwp-core/src/wpHooks/overloads.ts | 12 ++-- .../tests/apollo/errorLoggingLink.test.ts | 14 ++-- .../tests/components/Toolbar/Toolbar.test.tsx | 2 +- .../faustwp-core/tests/hooks/useAuth.test.ts | 65 +++++++++---------- 16 files changed, 94 insertions(+), 94 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaab4aac3..6bbd98bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17020,7 +17020,7 @@ "npm": ">=8" }, "peerDependencies": { - "@apollo/client": "^3.14.1", + "@apollo/client": "^3.14.1 || ^4.0.0", "@faustwp/core": ">=1.1.0", "@wordpress/style-engine": ">=1.18.0", "next": "^16.4.2", @@ -17281,7 +17281,7 @@ "npm": ">=8" }, "peerDependencies": { - "@apollo/client": ">=3.6.6 <4", + "@apollo/client": ">=3.6.6 <5", "next": "^16.4.2", "react": ">=17.0.2", "react-dom": ">=17.0.2" diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 3951664bd..c01e084a4 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -6,7 +6,7 @@ "module": "dist/mjs/index.js", "types": "dist/mjs/index.d.ts", "peerDependencies": { - "@apollo/client": "^3.14.1", + "@apollo/client": "^3.14.1 || ^4.0.0", "@faustwp/core": ">=1.1.0", "@wordpress/style-engine": ">=1.18.0", "next": "^16.4.2", diff --git a/packages/faustwp-core/package.json b/packages/faustwp-core/package.json index 5dce93613..92bd13204 100644 --- a/packages/faustwp-core/package.json +++ b/packages/faustwp-core/package.json @@ -6,7 +6,7 @@ "module": "dist/mjs/index.js", "types": "dist/cjs/index.d.ts", "peerDependencies": { - "@apollo/client": ">=3.6.6 <4", + "@apollo/client": ">=3.6.6 <5", "next": "^16.4.2", "react": ">=17.0.2", "react-dom": ">=17.0.2" diff --git a/packages/faustwp-core/src/apollo/errorLoggingLink.ts b/packages/faustwp-core/src/apollo/errorLoggingLink.ts index 657d01c69..342cace59 100644 --- a/packages/faustwp-core/src/apollo/errorLoggingLink.ts +++ b/packages/faustwp-core/src/apollo/errorLoggingLink.ts @@ -1,11 +1,4 @@ -import { - ApolloLink, - FetchResult, - NextLink, - Operation, - Observable, - ServerError, -} from '@apollo/client'; +import { ApolloLink, FetchResult, Operation, Observable } from '@apollo/client'; import { errorLog } from '../utils/log.js'; /** @@ -13,17 +6,20 @@ import { errorLog } from '../utils/log.js'; * @param error The error to check. * @returns A boolean indicating whether the error is a server error. */ -function isServerError(error: unknown): error is ServerError { - if ( +function isServerError( + error: unknown, +): error is { + response: Response; + statusCode: number; + result?: unknown; + bodyText?: string; +} { + return ( typeof error === 'object' && error !== null && 'response' in error && - 'result' in error && 'statusCode' in error - ) { - return true; - } - return false; + ); } /** @@ -39,8 +35,9 @@ export class ErrorLoggingLink extends ApolloLink { // eslint-disable-next-line class-methods-use-this request( operation: Operation, - forward: NextLink, - ): Observable | null { + // Apollo Client 3 calls this NextLink and 4 calls it ForwardFunction. + forward: NonNullable[1]>, + ): ReturnType { return new Observable( (observer: { next: (value: FetchResult) => void; @@ -59,7 +56,8 @@ export class ErrorLoggingLink extends ApolloLink { // Check if the error is a server error if (isServerError(error)) { errorLog('Server error:', error); - errorLog('Fetch result:', error.result); + // Apollo Client 3 parses the body into result; 4 keeps the raw bodyText. + errorLog('Fetch result:', error.result ?? error.bodyText); } else { errorLog('Network error:', error); } diff --git a/packages/faustwp-core/src/client.ts b/packages/faustwp-core/src/client.ts index a6d1b8f27..ed9f21f22 100644 --- a/packages/faustwp-core/src/client.ts +++ b/packages/faustwp-core/src/client.ts @@ -1,6 +1,5 @@ import { ApolloClient, - ApolloClientOptions, ApolloLink, createHttpLink, InMemoryCache, @@ -23,6 +22,13 @@ import { getConfig } from './config/index.js'; import { getGraphqlEndpoint } from './lib/getGraphqlEndpoint.js'; import { hooks } from './wpHooks/index.js'; +// Apollo Client 4 dropped the cache type parameter, so derive the types from +// the constructor to work with both 3.x and 4.x. +export type FaustApolloClient = InstanceType; +export type FaustApolloClientOptions = ConstructorParameters< + typeof ApolloClient +>[0]; + export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'; declare global { @@ -34,8 +40,8 @@ declare global { const windowApolloState = typeof window !== 'undefined' ? window[APOLLO_STATE_PROP_NAME] : {}; -let apolloClient: ApolloClient | undefined; -let apolloAuthClient: ApolloClient | undefined; +let apolloClient: FaustApolloClient | undefined; +let apolloAuthClient: FaustApolloClient | undefined; export function createApolloClient(authenticated = false) { const { possibleTypes, usePersistedQueries, useGETForQueries } = getConfig(); @@ -100,7 +106,7 @@ export function createApolloClient(authenticated = false) { }).concat(linkChain); } - let apolloClientOptions: ApolloClientOptions = { + let apolloClientOptions: FaustApolloClientOptions = { ssrMode: typeof window === 'undefined', devtools: { enabled: typeof window !== 'undefined', @@ -113,7 +119,7 @@ export function createApolloClient(authenticated = false) { 'apolloClientOptions', apolloClientOptions, {}, - ) as ApolloClientOptions; + ) as FaustApolloClientOptions; return new ApolloClient(apolloClientOptions); } @@ -126,7 +132,7 @@ export function getApolloClient(initialState = null) { // gets hydrated here if (initialState) { // Get existing cache, loaded during client side data fetching - const existingCache = _apolloClient.extract(); + const existingCache = _apolloClient.extract() as NormalizedCacheObject; // Merge the initialState from getStaticProps/getServerSideProps in the existing cache const data = merge(existingCache, initialState, { @@ -158,7 +164,7 @@ export function getApolloAuthClient() { } export function addApolloState( - client: ApolloClient, + client: FaustApolloClient, pageProps: AppProps<{ props: { [key: string]: any }; revalidate?: number | boolean; diff --git a/packages/faustwp-core/src/components/FaustProvider.tsx b/packages/faustwp-core/src/components/FaustProvider.tsx index 243cc78d2..3820f21f9 100644 --- a/packages/faustwp-core/src/components/FaustProvider.tsx +++ b/packages/faustwp-core/src/components/FaustProvider.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; -import { ApolloProvider } from '@apollo/client'; +// eslint-disable-next-line import/extensions +import { ApolloProvider } from '@apollo/client/react'; // eslint-disable-next-line import/extensions import { useRouter } from 'next/router'; // eslint-disable-next-line import/extensions diff --git a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index b56a028bd..37665c002 100644 --- a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx @@ -1,4 +1,6 @@ -import { gql, useQuery } from '@apollo/client'; +import { gql } from '@apollo/client'; +// eslint-disable-next-line import/extensions +import { useQuery } from '@apollo/client/react'; import React, { useEffect, useMemo, useState } from 'react'; import { getApolloAuthClient } from '../../client.js'; import { useAuth } from '../../hooks/useAuth.js'; @@ -167,7 +169,7 @@ export function AuthenticatedToolbar({ seedNode }: ToolbarProps) { */ export function ToolbarAwaitUser({ seedNode }: ToolbarProps) { const client = getApolloAuthClient(); - const { data, error } = useQuery( + const { data, error } = useQuery( gql` { viewer { diff --git a/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx b/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx index efebee2e8..7882246e6 100644 --- a/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx +++ b/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { gql, useQuery } from '@apollo/client'; +import { gql } from '@apollo/client'; +// eslint-disable-next-line import/extensions +import { useQuery } from '@apollo/client/react'; import { getApolloAuthClient } from '../../../client.js'; import { useLogout } from '../../../hooks/useLogout.js'; import { useAuth } from '../../../hooks/useAuth.js'; @@ -14,7 +16,7 @@ import { export function AuthenticatedAccount() { const client = getApolloAuthClient(); const { logout } = useLogout(); - const { data, loading } = useQuery( + const { data, loading } = useQuery( gql` { viewer { diff --git a/packages/faustwp-core/src/components/WordPressTemplate.tsx b/packages/faustwp-core/src/components/WordPressTemplate.tsx index ad3ec5d00..1abc34481 100644 --- a/packages/faustwp-core/src/components/WordPressTemplate.tsx +++ b/packages/faustwp-core/src/components/WordPressTemplate.tsx @@ -161,7 +161,7 @@ export function WordPressTemplateInternal( const queryVariables = variables ? variables(seedNode, { asPreview: isPreview, locale }) : undefined; - return client.query({ + return client.query({ query, variables: queryVariables, }); @@ -226,7 +226,7 @@ export function WordPressTemplateInternal( : undefined, }; - const templateQueryRes = await client.query(queryArgs); + const templateQueryRes = await client.query(queryArgs); setData(templateQueryRes.data); @@ -380,7 +380,7 @@ export function WordPressTemplate(props: WordPressTemplateProps) { setLoading(true); - const seedQueryRes = await client.query(queryArgs); + const seedQueryRes = await client.query(queryArgs); const node = isPreview ? (getPreviewSeedNode(seedQueryRes?.data) as SeedNode) diff --git a/packages/faustwp-core/src/getWordPressProps.tsx b/packages/faustwp-core/src/getWordPressProps.tsx index 6b43bd8a2..b21018e50 100644 --- a/packages/faustwp-core/src/getWordPressProps.tsx +++ b/packages/faustwp-core/src/getWordPressProps.tsx @@ -128,7 +128,7 @@ export async function getWordPressProps( resolvedUrl, }) as DocumentNode; - const seedQueryRes = await client.query({ + const seedQueryRes = await client.query({ query: seedQuery, variables: { uri: resolvedUrl }, }); @@ -176,7 +176,7 @@ export async function getWordPressProps( : undefined; if (template.query) { - templateQueryRes = await client.query({ + templateQueryRes = await client.query({ query: template.query, variables: templateVariables, }); @@ -195,7 +195,7 @@ export async function getWordPressProps( extra, ) : undefined; - return client.query({ + return client.query({ query, variables: queryVariables, }); diff --git a/packages/faustwp-core/src/hooks/useAuth.tsx b/packages/faustwp-core/src/hooks/useAuth.tsx index 1f4d62ff4..cef19ecb5 100644 --- a/packages/faustwp-core/src/hooks/useAuth.tsx +++ b/packages/faustwp-core/src/hooks/useAuth.tsx @@ -207,7 +207,7 @@ export function useAuth(_config?: UseAuthConfig) { (async () => { const client = getApolloAuthClient(); - const { data } = await client.query({ + const { data } = await client.query({ // Always ask WordPress, and never share a request with an earlier // session's, since a different user may have logged in since. fetchPolicy: 'network-only', diff --git a/packages/faustwp-core/src/hooks/useLogin.tsx b/packages/faustwp-core/src/hooks/useLogin.tsx index 5b8acd70a..b8efc222a 100644 --- a/packages/faustwp-core/src/hooks/useLogin.tsx +++ b/packages/faustwp-core/src/hooks/useLogin.tsx @@ -1,4 +1,6 @@ -import { gql, useMutation } from '@apollo/client'; +import { gql } from '@apollo/client'; +// eslint-disable-next-line import/extensions +import { useMutation } from '@apollo/client/react'; import { useEffect, useState } from 'react'; import { fetchAccessToken } from '../auth/client/accessToken.js'; import { isValidEmail } from '../utils/index.js'; diff --git a/packages/faustwp-core/src/wpHooks/overloads.ts b/packages/faustwp-core/src/wpHooks/overloads.ts index a516d9b42..c9594d338 100644 --- a/packages/faustwp-core/src/wpHooks/overloads.ts +++ b/packages/faustwp-core/src/wpHooks/overloads.ts @@ -1,13 +1,9 @@ -import { - ApolloClientOptions, - DocumentNode, - InMemoryCacheConfig, - NormalizedCacheObject, -} from '@apollo/client'; +import { DocumentNode, InMemoryCacheConfig } from '@apollo/client'; import { _Hooks } from '@wordpress/hooks/build-types/createHooks.js'; import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; import { FaustToolbarNodes } from '../components/Toolbar/index.js'; import { SeedNode } from '../queries/seedQuery.js'; +import type { FaustApolloClientOptions } from '../client.js'; type FaustCoreFilters = { addFilter( @@ -24,9 +20,9 @@ type FaustCoreFilters = { hookName: 'apolloClientOptions', namespace: string, callback: ( - apolloClientOptions: ApolloClientOptions, + apolloClientOptions: FaustApolloClientOptions, context: Record, - ) => ApolloClientOptions, + ) => FaustApolloClientOptions, priority?: number | undefined, ): void; diff --git a/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts b/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts index 289f4e2fc..bd147d50e 100644 --- a/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts +++ b/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts @@ -1,4 +1,4 @@ -import { ApolloError } from '@apollo/client/core'; +import { GraphQLError } from 'graphql'; import { Observable } from 'zen-observable-ts'; import { ErrorLoggingLink } from '../../src/apollo/errorLoggingLink'; @@ -16,9 +16,7 @@ describe('ErrorLoggingLink', () => { }); it('logs GraphQL errors', (done) => { - const mockErrors = [ - new ApolloError({ errorMessage: 'Test GraphQL error' }), - ]; + const mockErrors = [new GraphQLError('Test GraphQL error')]; const mockResult = { errors: mockErrors }; const mockOperation = { query: {} }; @@ -54,10 +52,10 @@ describe('ErrorLoggingLink', () => { }, 1000); it('logs server errors along with fetch result', (done) => { - const mockServerError = new ApolloError({ - errorMessage: 'Test server error', - graphQLErrors: [], - networkError: null, // Ensure networkError is null to simulate a server error + const mockServerError = Object.assign(new Error('Test server error'), { + response: {}, + statusCode: 500, + result: { message: 'Internal server error' }, }); const mockOperation = { query: {} }; diff --git a/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx b/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx index e87e4f54a..dfc64b96e 100644 --- a/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx +++ b/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx @@ -6,7 +6,7 @@ import { waitFor, queryByAttribute, within } from '@testing-library/dom'; import { Hooks } from '@wordpress/hooks/build-types'; import { FaustToolbarNodes } from '../../../src'; import { setConfig } from '../../../src/config/index'; -import * as apollo from '@apollo/client'; +import * as apollo from '@apollo/client/react'; import * as nextRouter from 'next/router'; import { Toolbar } from '../../../src/components/Toolbar'; import { OperationVariables, QueryResult } from '@apollo/client'; diff --git a/packages/faustwp-core/tests/hooks/useAuth.test.ts b/packages/faustwp-core/tests/hooks/useAuth.test.ts index d905a207d..de4d49513 100644 --- a/packages/faustwp-core/tests/hooks/useAuth.test.ts +++ b/packages/faustwp-core/tests/hooks/useAuth.test.ts @@ -7,7 +7,8 @@ import fetchMock from 'fetch-mock'; import { useAuth } from '../../src/hooks/useAuth'; import { setAccessToken } from '../../src/auth'; -const VIEWER_URL = `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`; +// Apollo Client 4 appends an extensions param, so match on the start of the URL. +const VIEWER_URL = `begin:http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`; /** * A viewer with every field useAuth asks for, so Apollo can answer the query from its cache. @@ -75,24 +76,21 @@ describe('useAuth hook', () => { status: 200, body: JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }), }); - fetchMock.get( - `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`, - { - status: 200, - body: JSON.stringify({ - data: { - viewer: { - name: 'admin', - username: 'admin', - capabilities: ['administrator'], - databaseId: 1, - description: '', - email: '', - }, + fetchMock.get(VIEWER_URL, { + status: 200, + body: JSON.stringify({ + data: { + viewer: { + name: 'admin', + username: 'admin', + capabilities: ['administrator'], + databaseId: 1, + description: '', + email: '', }, - }), - }, - ); + }, + }), + }); const { result } = renderHook(() => useAuth()); @@ -157,24 +155,21 @@ describe('useAuth hook', () => { }), }); - fetchMock.get( - `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`, - { - status: 200, - body: JSON.stringify({ - data: { - viewer: { - name: 'admin', - username: 'admin', - capabilities: ['administrator'], - databaseId: 1, - description: '', - email: '', - }, + fetchMock.get(VIEWER_URL, { + status: 200, + body: JSON.stringify({ + data: { + viewer: { + name: 'admin', + username: 'admin', + capabilities: ['administrator'], + databaseId: 1, + description: '', + email: '', }, - }), - }, - ); + }, + }), + }); const { result } = renderHook(() => useAuth()); From 033e767ee6aa292997ee7bec26b55d891474eb14 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 13:53:22 -0400 Subject: [PATCH 02/13] docs: explain how to use Apollo Client 4 --- README.md | 2 +- docs/explanation/apollo-client-basics/index.md | 2 +- docs/how-to/basic-setup/index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72af55837..f54a6e626 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Faust.js is a toolkit for building Next.js applications for headless WordPress s - Node.js 22 or newer and npm 8 or newer. Use the latest release of a [supported version](https://nodejs.org/en/about/previous-releases) so you have its security fixes. Node.js 20 has reached end of life. If you need to stay on it, use `@faustwp/core` and `@faustwp/cli` 3.x and `@faustwp/blocks` 6.x. - Next.js 12.1.6 or newer with the Pages Router (15.5.5 or newer if you use `@faustwp/blocks`). The examples and tests use Next.js 16. - React 17.0.2 or newer, and at least the version your Next.js release requires (18.2 for Next.js 16). -- Apollo Client 3.6.6 or newer, below 4 (3.14 or newer if you use `@faustwp/blocks`). +- Apollo Client 3.6.6 or newer, including 4 (3.14 or newer if you use `@faustwp/blocks`). - macOS, Windows (including WSL) or Linux. ### WordPress diff --git a/docs/explanation/apollo-client-basics/index.md b/docs/explanation/apollo-client-basics/index.md index 7b016ab9b..e351d3a39 100644 --- a/docs/explanation/apollo-client-basics/index.md +++ b/docs/explanation/apollo-client-basics/index.md @@ -3,7 +3,7 @@ title: "Apollo Client Basics" description: "Learn about core Apollo Client concepts like queries, fragments, mutations, and caching as they relate to Faust.js and WordPress." --- -Faust.js uses `@apollo/client@3` under the hood to perform GraphQL operations against your WordPress backend. Having a solid understanding of Apollo Client queries, fragments, and mutations will help you get the most out of Faust.js. +Faust.js uses `@apollo/client` (version 3 or 4) under the hood to perform GraphQL operations against your WordPress backend. Having a solid understanding of Apollo Client queries, fragments, and mutations will help you get the most out of Faust.js. ## Apollo Client GraphQL Concepts diff --git a/docs/how-to/basic-setup/index.md b/docs/how-to/basic-setup/index.md index 10013b5ce..221ab6935 100644 --- a/docs/how-to/basic-setup/index.md +++ b/docs/how-to/basic-setup/index.md @@ -51,7 +51,7 @@ npm install @apollo/client@3 @faustwp/core graphql @faustwp/cli ``` > [!NOTE] -> Faust supports Apollo Client 3.x. Apollo Client 4 isn't supported, and npm stops with a peer dependency error (`ERESOLVE`) if you try to install it alongside `@faustwp/core`. +> Faust also supports Apollo Client 4. To use it, install `@apollo/client@4` and `rxjs` instead, and import React hooks like `useQuery` from `@apollo/client/react`. ### D. Create Faust config file From a27b28e6dc7b687f814b6e43290898904c273a43 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 13:55:39 -0400 Subject: [PATCH 03/13] fix(core): keep Apollo Client 4 request URLs the same as Apollo Client 3 --- packages/faustwp-core/src/client.ts | 3 + .../faustwp-core/tests/hooks/useAuth.test.ts | 65 ++++++++++--------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/faustwp-core/src/client.ts b/packages/faustwp-core/src/client.ts index ed9f21f22..c03a3fcc7 100644 --- a/packages/faustwp-core/src/client.ts +++ b/packages/faustwp-core/src/client.ts @@ -113,6 +113,9 @@ export function createApolloClient(authenticated = false) { }, link: linkChain, cache: new InMemoryCache(inMemoryCacheObject).restore(windowApolloState), + // Apollo Client 4 adds its name and version to every request. Leave them + // out so GET URLs, and any caches keyed on them, match Apollo Client 3. + ...({ enhancedClientAwareness: { transport: false } } as object), }; apolloClientOptions = hooks.applyFilters( diff --git a/packages/faustwp-core/tests/hooks/useAuth.test.ts b/packages/faustwp-core/tests/hooks/useAuth.test.ts index de4d49513..d905a207d 100644 --- a/packages/faustwp-core/tests/hooks/useAuth.test.ts +++ b/packages/faustwp-core/tests/hooks/useAuth.test.ts @@ -7,8 +7,7 @@ import fetchMock from 'fetch-mock'; import { useAuth } from '../../src/hooks/useAuth'; import { setAccessToken } from '../../src/auth'; -// Apollo Client 4 appends an extensions param, so match on the start of the URL. -const VIEWER_URL = `begin:http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`; +const VIEWER_URL = `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`; /** * A viewer with every field useAuth asks for, so Apollo can answer the query from its cache. @@ -76,21 +75,24 @@ describe('useAuth hook', () => { status: 200, body: JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }), }); - fetchMock.get(VIEWER_URL, { - status: 200, - body: JSON.stringify({ - data: { - viewer: { - name: 'admin', - username: 'admin', - capabilities: ['administrator'], - databaseId: 1, - description: '', - email: '', + fetchMock.get( + `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`, + { + status: 200, + body: JSON.stringify({ + data: { + viewer: { + name: 'admin', + username: 'admin', + capabilities: ['administrator'], + databaseId: 1, + description: '', + email: '', + }, }, - }, - }), - }); + }), + }, + ); const { result } = renderHook(() => useAuth()); @@ -155,21 +157,24 @@ describe('useAuth hook', () => { }), }); - fetchMock.get(VIEWER_URL, { - status: 200, - body: JSON.stringify({ - data: { - viewer: { - name: 'admin', - username: 'admin', - capabilities: ['administrator'], - databaseId: 1, - description: '', - email: '', + fetchMock.get( + `http://headless.local/index.php?graphql&query=query%20GetFaustViewer%20%7B%0A%20%20viewer%20%7B%0A%20%20%20%20name%0A%20%20%20%20username%0A%20%20%20%20capabilities%0A%20%20%20%20databaseId%0A%20%20%20%20description%0A%20%20%20%20email%0A%20%20%20%20firstName%0A%20%20%20%20id%0A%20%20%20%20lastName%0A%20%20%20%20nickname%0A%20%20%20%20locale%0A%20%20%20%20registeredDate%0A%20%20%20%20slug%0A%20%20%20%20templates%0A%20%20%20%20uri%0A%20%20%20%20url%0A%20%20%20%20userId%0A%20%20%20%20avatar%20%7B%0A%20%20%20%20%20%20url%0A%20%20%20%20%20%20__typename%0A%20%20%20%20%7D%0A%20%20%20%20__typename%0A%20%20%7D%0A%7D&operationName=GetFaustViewer&variables=%7B%7D`, + { + status: 200, + body: JSON.stringify({ + data: { + viewer: { + name: 'admin', + username: 'admin', + capabilities: ['administrator'], + databaseId: 1, + description: '', + email: '', + }, }, - }, - }), - }); + }), + }, + ); const { result } = renderHook(() => useAuth()); From dcbcb029477afedfdb801df8a0897e43cd38a4d4 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 14:04:57 -0400 Subject: [PATCH 04/13] fix(core): write the exported Apollo types so they fit Apollo Client 3 and 4 --- .../faustwp-core/src/apollo/errorLoggingLink.ts | 4 +--- packages/faustwp-core/src/client.ts | 15 ++++++++------- packages/faustwp-core/src/hooks/useLogin.tsx | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/faustwp-core/src/apollo/errorLoggingLink.ts b/packages/faustwp-core/src/apollo/errorLoggingLink.ts index 342cace59..60fa1290a 100644 --- a/packages/faustwp-core/src/apollo/errorLoggingLink.ts +++ b/packages/faustwp-core/src/apollo/errorLoggingLink.ts @@ -6,9 +6,7 @@ import { errorLog } from '../utils/log.js'; * @param error The error to check. * @returns A boolean indicating whether the error is a server error. */ -function isServerError( - error: unknown, -): error is { +function isServerError(error: unknown): error is { response: Response; statusCode: number; result?: unknown; diff --git a/packages/faustwp-core/src/client.ts b/packages/faustwp-core/src/client.ts index c03a3fcc7..93fba388d 100644 --- a/packages/faustwp-core/src/client.ts +++ b/packages/faustwp-core/src/client.ts @@ -22,9 +22,10 @@ import { getConfig } from './config/index.js'; import { getGraphqlEndpoint } from './lib/getGraphqlEndpoint.js'; import { hooks } from './wpHooks/index.js'; -// Apollo Client 4 dropped the cache type parameter, so derive the types from -// the constructor to work with both 3.x and 4.x. -export type FaustApolloClient = InstanceType; +// Apollo Client 4 dropped the cache type parameter. These types are worked out +// in the app's own project, so they fit whichever version it installs: with +// 3.x the client is ApolloClient, with 4.x it's ApolloClient. +export type FaustApolloClient = typeof ApolloClient.prototype; export type FaustApolloClientOptions = ConstructorParameters< typeof ApolloClient >[0]; @@ -43,7 +44,7 @@ const windowApolloState = let apolloClient: FaustApolloClient | undefined; let apolloAuthClient: FaustApolloClient | undefined; -export function createApolloClient(authenticated = false) { +export function createApolloClient(authenticated = false): FaustApolloClient { const { possibleTypes, usePersistedQueries, useGETForQueries } = getConfig(); let inMemoryCacheObject: InMemoryCacheConfig = { @@ -127,7 +128,7 @@ export function createApolloClient(authenticated = false) { return new ApolloClient(apolloClientOptions); } -export function getApolloClient(initialState = null) { +export function getApolloClient(initialState = null): FaustApolloClient { // eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle const _apolloClient = apolloClient ?? createApolloClient(); @@ -157,7 +158,7 @@ export function getApolloClient(initialState = null) { return _apolloClient; } -export function getApolloAuthClient() { +export function getApolloAuthClient(): FaustApolloClient { // eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle const _apolloAuthClient = apolloAuthClient ?? createApolloClient(true); @@ -186,7 +187,7 @@ export function addApolloState( export function useApollo( pageProps: AppProps<{ [key: string]: any }>['pageProps'], -) { +): FaustApolloClient { const state = pageProps[APOLLO_STATE_PROP_NAME]; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const store = useMemo(() => getApolloClient(state), [state]); diff --git a/packages/faustwp-core/src/hooks/useLogin.tsx b/packages/faustwp-core/src/hooks/useLogin.tsx index b8efc222a..f7a8f9112 100644 --- a/packages/faustwp-core/src/hooks/useLogin.tsx +++ b/packages/faustwp-core/src/hooks/useLogin.tsx @@ -34,7 +34,20 @@ export type GenerateAuthCodeMutationRes = }; }; -export function useLogin() { +export type UseLoginResult = { + login: ( + usernameEmail: string, + password: string, + redirectUrl?: string, + ) => void; + loading: boolean; + data: GenerateAuthCodeMutationRes | null | undefined; + // Written this way so it's ApolloError with Apollo Client 3 and the new + // error types with Apollo Client 4. + error: ReturnType[1]['error']; +}; + +export function useLogin(): UseLoginResult { /** * Use our own data state value here instead of the data value from * the useMutation because we only want to return the data after the From ba93ea2edf368d1ffbb01fb4f8545f8293b7baaa Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 14:06:28 -0400 Subject: [PATCH 05/13] docs: import Apollo React hooks from @apollo/client/react so examples work with Apollo Client 3 and 4 --- docs/how-to/authentication/index.md | 6 ++++-- docs/how-to/basic-setup/index.md | 2 +- docs/how-to/query-data-in-the-browser/index.md | 9 ++++++--- docs/how-to/write-data-to-wordpress/index.md | 3 ++- docs/reference/use-auth/index.md | 3 ++- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/how-to/authentication/index.md b/docs/how-to/authentication/index.md index 004b92798..38f6a5aaf 100644 --- a/docs/how-to/authentication/index.md +++ b/docs/how-to/authentication/index.md @@ -35,7 +35,8 @@ You are now ready to implement the auth functionality. Create a `/gated/index.js ```js title="pages/gated/index.js" import { useAuth, getApolloAuthClient, useLogout } from "@faustwp/core"; -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; function AuthenticatedView() { const client = getApolloAuthClient(); @@ -151,7 +152,8 @@ Here are some things you can do to continue to test and add to your auth strateg ```js title="pages/gated/index.js" import { useAuth, getApolloAuthClient, useLogout } from "@faustwp/core"; -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; function AuthenticatedView() { const client = getApolloAuthClient(); diff --git a/docs/how-to/basic-setup/index.md b/docs/how-to/basic-setup/index.md index 221ab6935..4f8296a67 100644 --- a/docs/how-to/basic-setup/index.md +++ b/docs/how-to/basic-setup/index.md @@ -51,7 +51,7 @@ npm install @apollo/client@3 @faustwp/core graphql @faustwp/cli ``` > [!NOTE] -> Faust also supports Apollo Client 4. To use it, install `@apollo/client@4` and `rxjs` instead, and import React hooks like `useQuery` from `@apollo/client/react`. +> Faust also supports Apollo Client 4. To use it, install `@apollo/client@4` and `rxjs` instead of `@apollo/client@3`. Import React hooks like `useQuery` from `@apollo/client/react`, which works with both versions. ### D. Create Faust config file diff --git a/docs/how-to/query-data-in-the-browser/index.md b/docs/how-to/query-data-in-the-browser/index.md index de6cf333a..066e9f774 100644 --- a/docs/how-to/query-data-in-the-browser/index.md +++ b/docs/how-to/query-data-in-the-browser/index.md @@ -29,7 +29,8 @@ Make sure you can navigate to this page in your browser. To query data in the browser, you need to import the `useQuery` from the `@apollo/client` package. This hook will execute queries using the same client used by templates on the server. That client comes from [`FaustProvider`](/docs/reference/faust-provider/), which also explains how to customize it. ```js title="page/client.js" {1,4} -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; export default function Page(props) { const { data } = useQuery(); @@ -43,7 +44,8 @@ export default function Page(props) { It's time to write our GraphQL query to fetch some data. For this example we'll be fetching the site title and description from your WP site's general settings. ```js {4,9-17} title="page/client.js" -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; export default function Page(props) { const { data } = useQuery(Page.query); @@ -69,7 +71,8 @@ Page.query = gql` Now that we have our query, we can display the data on the page. ```js {5, 7-12} title="page/client.js" -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; export default function Page(props) { const { data } = useQuery(Page.query); diff --git a/docs/how-to/write-data-to-wordpress/index.md b/docs/how-to/write-data-to-wordpress/index.md index fd9459e2c..e9b61617c 100644 --- a/docs/how-to/write-data-to-wordpress/index.md +++ b/docs/how-to/write-data-to-wordpress/index.md @@ -30,7 +30,8 @@ Pass the auth client to Apollo's `useMutation` hook, the same way you pass it to ```js title="components/NewDraft.js" import { getApolloAuthClient } from "@faustwp/core"; -import { gql, useMutation } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; const CREATE_DRAFT = gql` mutation CreateDraft($title: String!, $content: String) { diff --git a/docs/reference/use-auth/index.md b/docs/reference/use-auth/index.md index 7c4fdd22a..85d375c5e 100644 --- a/docs/reference/use-auth/index.md +++ b/docs/reference/use-auth/index.md @@ -98,7 +98,8 @@ type UseAuth = { Below is a full example of login/logout and showing/hiding authenticated content. This below example uses the “redirect” strategy: ```js -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import { getApolloAuthClient, useAuth } from "@faustwp/core"; import { useLogout } from "@faustwp/core"; From 88d2922ea94e2e0ba0c228176f44ac354ea741f9 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 22 Oct 2026 19:14:58 -0400 Subject: [PATCH 06/13] docs: drop Apollo Client version numbers from the docs --- README.md | 2 +- docs/explanation/apollo-client-basics/index.md | 2 +- docs/explanation/telemetry/index.md | 2 +- docs/how-to/basic-setup/index.md | 4 ++-- docs/how-to/migrate-from-legacy-faust/index.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f54a6e626..5811a6b5e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Faust.js is a toolkit for building Next.js applications for headless WordPress s - Node.js 22 or newer and npm 8 or newer. Use the latest release of a [supported version](https://nodejs.org/en/about/previous-releases) so you have its security fixes. Node.js 20 has reached end of life. If you need to stay on it, use `@faustwp/core` and `@faustwp/cli` 3.x and `@faustwp/blocks` 6.x. - Next.js 12.1.6 or newer with the Pages Router (15.5.5 or newer if you use `@faustwp/blocks`). The examples and tests use Next.js 16. - React 17.0.2 or newer, and at least the version your Next.js release requires (18.2 for Next.js 16). -- Apollo Client 3.6.6 or newer, including 4 (3.14 or newer if you use `@faustwp/blocks`). +- Apollo Client. - macOS, Windows (including WSL) or Linux. ### WordPress diff --git a/docs/explanation/apollo-client-basics/index.md b/docs/explanation/apollo-client-basics/index.md index e351d3a39..5aa9d1713 100644 --- a/docs/explanation/apollo-client-basics/index.md +++ b/docs/explanation/apollo-client-basics/index.md @@ -3,7 +3,7 @@ title: "Apollo Client Basics" description: "Learn about core Apollo Client concepts like queries, fragments, mutations, and caching as they relate to Faust.js and WordPress." --- -Faust.js uses `@apollo/client` (version 3 or 4) under the hood to perform GraphQL operations against your WordPress backend. Having a solid understanding of Apollo Client queries, fragments, and mutations will help you get the most out of Faust.js. +Faust.js uses `@apollo/client` under the hood to perform GraphQL operations against your WordPress backend. Having a solid understanding of Apollo Client queries, fragments, and mutations will help you get the most out of Faust.js. ## Apollo Client GraphQL Concepts diff --git a/docs/explanation/telemetry/index.md b/docs/explanation/telemetry/index.md index 49fcffab7..943446508 100644 --- a/docs/explanation/telemetry/index.md +++ b/docs/explanation/telemetry/index.md @@ -23,7 +23,7 @@ Faust collects completely anonymous telemetry data about general usage. Particip - `@faustwp/blocks` version - `@faustwp/block-editor-utils` version - `@faustwp/experimental-app-router` version -- `@apollo/client@3` version +- `@apollo/client` version - Node version - Next.js version - If the Node environment is in **dev mode** (i.e., whether `npm run dev` was run) diff --git a/docs/how-to/basic-setup/index.md b/docs/how-to/basic-setup/index.md index 4f8296a67..05b0865ab 100644 --- a/docs/how-to/basic-setup/index.md +++ b/docs/how-to/basic-setup/index.md @@ -47,11 +47,11 @@ Save your `.env.local` file. Install the required NPM packages in your Next.js app. ```bash -npm install @apollo/client@3 @faustwp/core graphql @faustwp/cli +npm install @apollo/client @faustwp/core graphql @faustwp/cli ``` > [!NOTE] -> Faust also supports Apollo Client 4. To use it, install `@apollo/client@4` and `rxjs` instead of `@apollo/client@3`. Import React hooks like `useQuery` from `@apollo/client/react`, which works with both versions. +> Import Apollo's React hooks, like `useQuery`, from `@apollo/client/react`. ### D. Create Faust config file diff --git a/docs/how-to/migrate-from-legacy-faust/index.md b/docs/how-to/migrate-from-legacy-faust/index.md index 442e0b8ea..bc2fc48a7 100644 --- a/docs/how-to/migrate-from-legacy-faust/index.md +++ b/docs/how-to/migrate-from-legacy-faust/index.md @@ -148,7 +148,7 @@ Create a list of components that can be reused in the new version of Faust and m Start by installing the Faust.js core packages and dependencies in your terminal: ```bash -npm i @faustwp/cli @faustwp/core graphql @apollo/client@3 +npm i @faustwp/cli @faustwp/core graphql @apollo/client ``` Change the `package.json` scripts to use the new Faust cli: From 90ac1b3f8aaf27963b09ac755da0d7d58eece3f0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 10:38:31 -0400 Subject: [PATCH 07/13] refactor(core): create the HTTP link with the HttpLink class Apollo Client 4 recommends --- packages/faustwp-core/src/client.ts | 7 +++++-- packages/faustwp-core/tests/client.test.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/faustwp-core/src/client.ts b/packages/faustwp-core/src/client.ts index 93fba388d..4546e9e95 100644 --- a/packages/faustwp-core/src/client.ts +++ b/packages/faustwp-core/src/client.ts @@ -1,7 +1,7 @@ import { ApolloClient, ApolloLink, - createHttpLink, + HttpLink, InMemoryCache, InMemoryCacheConfig, NormalizedCacheObject, @@ -72,7 +72,7 @@ export function createApolloClient(authenticated = false): FaustApolloClient { let linkChain = ApolloLink.from([ new ErrorLoggingLink(), - createHttpLink({ + new HttpLink({ uri: getGraphqlEndpoint(), /** * Only add this option if usePersistedQueries is not set/false. @@ -83,6 +83,9 @@ export function createApolloClient(authenticated = false): FaustApolloClient { }), ]); + // Apollo Client 4 deprecates createPersistedQueryLink and setContext in favor + // of link classes that Apollo Client 3 doesn't have, so keep these for now. + // If the user requested to use persisted queries, apply the link. if (usePersistedQueries) { linkChain = createPersistedQueryLink({ diff --git a/packages/faustwp-core/tests/client.test.ts b/packages/faustwp-core/tests/client.test.ts index 90b57f928..98785fb2c 100644 --- a/packages/faustwp-core/tests/client.test.ts +++ b/packages/faustwp-core/tests/client.test.ts @@ -4,12 +4,22 @@ import * as persistedQueriesLink from '@apollo/client/link/persisted-queries'; import * as apolloLinkContext from '@apollo/client/link/context'; import { FaustConfig, setConfig } from '../src/config'; +// Wrap the HttpLink class so the tests can check the options it's created with. +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client'); + + return { + ...actual, + HttpLink: jest.fn((options) => new actual.HttpLink(options)), + }; +}); + afterEach(() => { jest.clearAllMocks(); }); describe('createApolloClient', () => { - const httpLinkSpy = jest.spyOn(apolloClient, 'createHttpLink'); + const httpLinkSpy = apolloClient.HttpLink as unknown as jest.Mock; const setContextLinkSpy = jest.spyOn(apolloLinkContext, 'setContext'); const persistedQueriesLinkSpy = jest.spyOn( persistedQueriesLink, From 7b68fa1ef2cf773db14e03124036383de859a62b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 10:40:04 -0400 Subject: [PATCH 08/13] ci: test the core and blocks packages with Apollo Client 4 --- .github/workflows/unit-test-packages.yml | 17 +++++++++++++++++ package.json | 1 + scripts/test-apollo-client-4.sh | 11 +++++++++++ 3 files changed, 29 insertions(+) create mode 100755 scripts/test-apollo-client-4.sh diff --git a/.github/workflows/unit-test-packages.yml b/.github/workflows/unit-test-packages.yml index 778044b9c..678c8a21c 100644 --- a/.github/workflows/unit-test-packages.yml +++ b/.github/workflows/unit-test-packages.yml @@ -45,3 +45,20 @@ jobs: uses: ./.github/actions/run-coverage with: working-directory: packages/faustwp-cli + + test-apollo-client-4: + name: "Test packages with Apollo Client 4" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Node.js 24 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + check-latest: true + + - name: Install + run: npm ci + + - name: Test with Apollo Client 4 + run: npm run test:apollo-client-4 diff --git a/package.json b/package.json index f6c56ea33..131bec8b5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev": "npm run build && npm run dev:next:getting-started", "dev:next:getting-started": "rimraf examples/next/faustwp-getting-started/.next && npm run dev ---workspace=examples/next/faustwp-getting-started", "test": "npm run build && npm test --workspace=@faustwp/core --workspace=@faustwp/cli --workspace=@faustwp/blocks", + "test:apollo-client-4": "bash scripts/test-apollo-client-4.sh", "test:format": "npm run test:format --workspace=@faustwp/cli --workspace=@faustwp/core -- --ignore-path .eslintignore", "wpe-build": "exit 1", "lint": "eslint ./packages --ext js,jsx,ts,tsx --max-warnings=0", diff --git a/scripts/test-apollo-client-4.sh b/scripts/test-apollo-client-4.sh new file mode 100755 index 000000000..430f97406 --- /dev/null +++ b/scripts/test-apollo-client-4.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run the core and blocks builds and tests against Apollo Client 4. The +# packages' dev dependencies stay on Apollo Client 3, so `npm test` covers 3. +set -euo pipefail + +npm install --no-save --no-audit --no-fund \ + --workspace=@faustwp/core --workspace=@faustwp/blocks \ + @apollo/client@^4 rxjs@^7 + +npm run build --workspace=@faustwp/core --workspace=@faustwp/blocks +npm test --workspace=@faustwp/core --workspace=@faustwp/blocks From 6586250a20065dd1b3598ada33e4b99f5ed12f8e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 10:41:40 -0400 Subject: [PATCH 09/13] test(core): send real queries through the Apollo link chain --- .../tests/apollo/linkChain.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 packages/faustwp-core/tests/apollo/linkChain.test.ts diff --git a/packages/faustwp-core/tests/apollo/linkChain.test.ts b/packages/faustwp-core/tests/apollo/linkChain.test.ts new file mode 100644 index 000000000..4b45f5b2b --- /dev/null +++ b/packages/faustwp-core/tests/apollo/linkChain.test.ts @@ -0,0 +1,89 @@ +/** + * @jest-environment jsdom + */ +import { gql } from '@apollo/client'; +import fetchMock from 'fetch-mock'; +import { setAccessToken } from '../../src/auth'; +import { createApolloClient } from '../../src/client'; + +// Sends real queries through createApolloClient's links, with only fetch +// mocked, so the requests are checked with whichever Apollo Client is +// installed (npm run test:apollo-client-4 runs this with version 4). + +const GET_TITLE = gql` + query GetTitle { + generalSettings { + title + } + } +`; + +const GRAPHQL_URL = 'begin:http://headless.local/index.php?graphql'; + +describe('Apollo link chain', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { + ...envBackup, + NEXT_PUBLIC_WORDPRESS_URL: 'http://headless.local', + }; + }); + + afterEach(() => { + process.env = envBackup; + setAccessToken(undefined, undefined); + fetchMock.restore(); + jest.restoreAllMocks(); + }); + + test('sends queries as GET without client library extensions', async () => { + fetchMock.get(GRAPHQL_URL, { + data: { + generalSettings: { title: 'Site', __typename: 'GeneralSettings' }, + }, + }); + + const { data } = await createApolloClient().query({ + query: GET_TITLE, + }); + + expect(data.generalSettings.title).toBe('Site'); + const url = new URL(fetchMock.lastUrl() as string); + expect(url.searchParams.get('operationName')).toBe('GetTitle'); + expect(url.searchParams.has('extensions')).toBe(false); + }); + + test('sends the access token from the auth client', async () => { + setAccessToken('test-token', Math.floor(Date.now() / 1000) + 300); + fetchMock.get(GRAPHQL_URL, { + data: { + generalSettings: { title: 'Site', __typename: 'GeneralSettings' }, + }, + }); + + await createApolloClient(true).query({ query: GET_TITLE }); + + const headers = new Headers( + fetchMock.lastOptions()?.headers as HeadersInit, + ); + expect(headers.get('authorization')).toBe('Bearer test-token'); + }); + + test('logs GraphQL errors and still rejects the query', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + fetchMock.get(GRAPHQL_URL, { + data: null, + errors: [{ message: 'Internal server error' }], + }); + + await expect( + createApolloClient().query({ query: GET_TITLE }), + ).rejects.toThrow('Internal server error'); + + const logged = consoleSpy.mock.calls + .map((args) => args.join(' ')) + .join('\n'); + expect(logged).toContain('GraphQL errors'); + }); +}); From f3c089a3b245cc9db2a5372b132129a88b43a50d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 11:04:33 -0400 Subject: [PATCH 10/13] fix(examples): import useQuery from @apollo/client/react so the examples build with Apollo Client 4 --- examples/next/custom-toolbar/pages/example.js | 3 ++- examples/next/custom-toolbar/wp-templates/front-page.js | 3 ++- examples/next/faustwp-getting-started/pages/example.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/next/custom-toolbar/pages/example.js b/examples/next/custom-toolbar/pages/example.js index b1c3866f2..ba0548ce7 100644 --- a/examples/next/custom-toolbar/pages/example.js +++ b/examples/next/custom-toolbar/pages/example.js @@ -1,4 +1,5 @@ -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import Head from "next/head"; import Header from "../components/header"; import EntryHeader from "../components/entry-header"; diff --git a/examples/next/custom-toolbar/wp-templates/front-page.js b/examples/next/custom-toolbar/wp-templates/front-page.js index b91d21d04..5f38b4dbb 100644 --- a/examples/next/custom-toolbar/wp-templates/front-page.js +++ b/examples/next/custom-toolbar/wp-templates/front-page.js @@ -1,4 +1,5 @@ -import { gql, useQuery } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import style from "../styles/front-page.module.css"; import { getApolloAuthClient, useAuth } from '@faustwp/core'; import { useLogout } from '@faustwp/core'; diff --git a/examples/next/faustwp-getting-started/pages/example.js b/examples/next/faustwp-getting-started/pages/example.js index 22e344203..7ea231cd5 100644 --- a/examples/next/faustwp-getting-started/pages/example.js +++ b/examples/next/faustwp-getting-started/pages/example.js @@ -1,4 +1,5 @@ -import { gql, useQuery } from '@apollo/client'; +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; import * as MENUS from '../constants/menus'; import { BlogInfoFragment } from '../fragments/GeneralSettings'; import { From 624ad0319262b8aebe155e940029a279c206b220 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 11:07:57 -0400 Subject: [PATCH 11/13] test(core): cover persisted queries in the Apollo link chain test --- .../tests/apollo/linkChain.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/faustwp-core/tests/apollo/linkChain.test.ts b/packages/faustwp-core/tests/apollo/linkChain.test.ts index 4b45f5b2b..94b7ca4ec 100644 --- a/packages/faustwp-core/tests/apollo/linkChain.test.ts +++ b/packages/faustwp-core/tests/apollo/linkChain.test.ts @@ -5,6 +5,7 @@ import { gql } from '@apollo/client'; import fetchMock from 'fetch-mock'; import { setAccessToken } from '../../src/auth'; import { createApolloClient } from '../../src/client'; +import { FaustConfig, setConfig } from '../../src/config'; // Sends real queries through createApolloClient's links, with only fetch // mocked, so the requests are checked with whichever Apollo Client is @@ -86,4 +87,31 @@ describe('Apollo link chain', () => { .join('\n'); expect(logged).toContain('GraphQL errors'); }); + + test('sends persisted queries as a hash without client library extensions', async () => { + setConfig({ + usePersistedQueries: true, + useGETForQueries: true, + } as FaustConfig); + fetchMock.get(GRAPHQL_URL, { + data: { + generalSettings: { title: 'Site', __typename: 'GeneralSettings' }, + }, + }); + + try { + await createApolloClient().query({ query: GET_TITLE }); + } finally { + setConfig({ + usePersistedQueries: false, + useGETForQueries: true, + } as FaustConfig); + } + + const url = new URL(fetchMock.lastUrl() as string); + const extensions = JSON.parse(url.searchParams.get('extensions') as string); + expect(url.searchParams.has('query')).toBe(false); + expect(extensions.persistedQuery.sha256Hash).toMatch(/^[0-9a-f]{64}$/); + expect(extensions).not.toHaveProperty('clientLibrary'); + }); }); From 47f12c4de70b60acc03671be1d90bba789e263ed Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 11:11:13 -0400 Subject: [PATCH 12/13] fix(core): type Faust's own query results and hide the toolbar when the session has ended --- .../src/components/Toolbar/Toolbar.tsx | 7 ++++-- .../components/Toolbar/nodes/MyAccount.tsx | 15 ++++++++++--- .../src/components/WordPressTemplate.tsx | 6 ++++- .../faustwp-core/src/getWordPressProps.tsx | 2 +- packages/faustwp-core/src/hooks/useAuth.tsx | 4 ++-- .../tests/components/Toolbar/Toolbar.test.tsx | 22 +++++++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index 37665c002..523d2245c 100644 --- a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx @@ -169,7 +169,9 @@ export function AuthenticatedToolbar({ seedNode }: ToolbarProps) { */ export function ToolbarAwaitUser({ seedNode }: ToolbarProps) { const client = getApolloAuthClient(); - const { data, error } = useQuery( + const { data, error } = useQuery<{ + viewer?: { shouldShowFaustToolbar?: boolean | null } | null; + }>( gql` { viewer { @@ -194,7 +196,8 @@ export function ToolbarAwaitUser({ seedNode }: ToolbarProps) { return null; } - if (data.viewer.shouldShowFaustToolbar === false) { + // WPGraphQL returns no viewer once the session has ended. + if (!data.viewer || data.viewer.shouldShowFaustToolbar === false) { return null; } diff --git a/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx b/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx index 7882246e6..fb206dcc8 100644 --- a/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx +++ b/packages/faustwp-core/src/components/Toolbar/nodes/MyAccount.tsx @@ -16,7 +16,16 @@ import { export function AuthenticatedAccount() { const client = getApolloAuthClient(); const { logout } = useLogout(); - const { data, loading } = useQuery( + const { data, loading } = useQuery<{ + viewer?: { + name?: string | null; + username?: string | null; + avatar26?: { url?: string | null } | null; + avatar52?: { url?: string | null } | null; + avatar64?: { url?: string | null } | null; + avatar128?: { url?: string | null } | null; + } | null; + }>( gql` { viewer { @@ -63,7 +72,7 @@ export function AuthenticatedAccount() { {data?.viewer?.avatar64?.url ? ( (queryArgs); + const seedQueryRes = await client.query< + NonNullable[0]> & { + nodeByUri?: SeedNode | null; + } + >(queryArgs); const node = isPreview ? (getPreviewSeedNode(seedQueryRes?.data) as SeedNode) diff --git a/packages/faustwp-core/src/getWordPressProps.tsx b/packages/faustwp-core/src/getWordPressProps.tsx index b21018e50..3ff04a553 100644 --- a/packages/faustwp-core/src/getWordPressProps.tsx +++ b/packages/faustwp-core/src/getWordPressProps.tsx @@ -128,7 +128,7 @@ export async function getWordPressProps( resolvedUrl, }) as DocumentNode; - const seedQueryRes = await client.query({ + const seedQueryRes = await client.query<{ nodeByUri?: SeedNode | null }>({ query: seedQuery, variables: { uri: resolvedUrl }, }); diff --git a/packages/faustwp-core/src/hooks/useAuth.tsx b/packages/faustwp-core/src/hooks/useAuth.tsx index cef19ecb5..6f631cb54 100644 --- a/packages/faustwp-core/src/hooks/useAuth.tsx +++ b/packages/faustwp-core/src/hooks/useAuth.tsx @@ -207,7 +207,7 @@ export function useAuth(_config?: UseAuthConfig) { (async () => { const client = getApolloAuthClient(); - const { data } = await client.query({ + const { data } = await client.query<{ viewer?: ViewerType | null }>({ // Always ask WordPress, and never share a request with an earlier // session's, since a different user may have logged in since. fetchPolicy: 'network-only', @@ -241,7 +241,7 @@ export function useAuth(_config?: UseAuthConfig) { }); // Ignore the result if the user logged in or out while it was loading. if (sessionRef.current === requestSession) { - setViewer(data.viewer as ViewerType | null); + setViewer(data?.viewer ?? null); } })(); }, [isAuthenticated, session, config.skip]); diff --git a/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx b/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx index dfc64b96e..1a7493a2d 100644 --- a/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx +++ b/packages/faustwp-core/tests/components/Toolbar/Toolbar.test.tsx @@ -85,6 +85,28 @@ test('doesnt render the toolbar if user preference is false', async () => { expect(navElement).not.toBeInTheDocument(); }); +test('doesnt render the toolbar once the session has ended', async () => { + expect.assertions(1); + mockIsAuthenticated = true; + + // WPGraphQL returns a null viewer when the user is no longer logged in. + const mockUseQuery = { + data: { + viewer: null, + }, + } as unknown as QueryResult; + + jest.spyOn(apollo, 'useQuery').mockReturnValue(mockUseQuery); + + const dom = render(); + + const navElement = await waitFor(() => + queryByAttribute('id', dom.container, 'wpadminbar'), + ); + + expect(navElement).not.toBeInTheDocument(); +}); + test('render the toolbar if user preference request throws an error/fails', async () => { expect.assertions(1); mockIsAuthenticated = true; From 09cc6fa3d74079bad5880b73c25d135c5dbe4c88 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 23 Oct 2026 12:21:49 -0400 Subject: [PATCH 13/13] chore(examples): leave the custom-toolbar example alone, since it's being removed --- examples/next/custom-toolbar/pages/example.js | 3 +-- examples/next/custom-toolbar/wp-templates/front-page.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/next/custom-toolbar/pages/example.js b/examples/next/custom-toolbar/pages/example.js index ba0548ce7..b1c3866f2 100644 --- a/examples/next/custom-toolbar/pages/example.js +++ b/examples/next/custom-toolbar/pages/example.js @@ -1,5 +1,4 @@ -import { gql } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; +import { gql, useQuery } from "@apollo/client"; import Head from "next/head"; import Header from "../components/header"; import EntryHeader from "../components/entry-header"; diff --git a/examples/next/custom-toolbar/wp-templates/front-page.js b/examples/next/custom-toolbar/wp-templates/front-page.js index 5f38b4dbb..b91d21d04 100644 --- a/examples/next/custom-toolbar/wp-templates/front-page.js +++ b/examples/next/custom-toolbar/wp-templates/front-page.js @@ -1,5 +1,4 @@ -import { gql } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; +import { gql, useQuery } from "@apollo/client"; import style from "../styles/front-page.module.css"; import { getApolloAuthClient, useAuth } from '@faustwp/core'; import { useLogout } from '@faustwp/core';