-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Mechanism to sync server prefetch with client API calls (#1798)
* Warn on API calls during initial render not prefetched * Full prefetch for homepage (commented) * Prefetch utility * Check for queries prefetched that are not needed during render and warn * No need to stringify * Replace useQuery overrides with decoupled cache check (wip) * Observer count for unnecessary prefetch warnings * Remove useQuery override * Test prefetch warnings * Remove inadvertent/unnecessary diff * Remove comments * Remove comment * Update frontends/api/src/ssr/usePrefetchWarnings.test.ts Co-authored-by: Chris Chudzicki <[email protected]> * Remove comment as no longer true * Less specific object assertion --------- Co-authored-by: Chris Chudzicki <[email protected]>
- Loading branch information
1 parent
41d284d
commit 1a2b98e
Showing
13 changed files
with
300 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { QueryClient, dehydrate } from "@tanstack/react-query" | ||
import type { Query } from "@tanstack/react-query" | ||
|
||
// Utility to avoid repetition in server components | ||
export const prefetch = async (queries: (Query | unknown)[]) => { | ||
const queryClient = new QueryClient() | ||
|
||
await Promise.all( | ||
queries.map((query) => queryClient.prefetchQuery(query as Query)), | ||
) | ||
|
||
return dehydrate(queryClient) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import { renderHook } from "@testing-library/react" | ||
import { useQuery } from "@tanstack/react-query" | ||
import { usePrefetchWarnings } from "./usePrefetchWarnings" | ||
import { setupReactQueryTest } from "../hooks/test-utils" | ||
import { urls, factories, setMockResponse } from "../test-utils" | ||
import { | ||
learningResourcesKeyFactory, | ||
useLearningResourcesDetail, | ||
} from "../hooks/learningResources" | ||
|
||
jest.mock("./usePrefetchWarnings", () => { | ||
const originalModule = jest.requireActual("./usePrefetchWarnings") | ||
return { | ||
...originalModule, | ||
logQueries: jest.fn(), | ||
} | ||
}) | ||
|
||
describe("SSR prefetch warnings", () => { | ||
beforeEach(() => { | ||
jest.spyOn(console, "info").mockImplementation(() => {}) | ||
jest.spyOn(console, "table").mockImplementation(() => {}) | ||
}) | ||
|
||
it("Warns if a query is requested on the client that has not been prefetched", async () => { | ||
const { wrapper, queryClient } = setupReactQueryTest() | ||
|
||
const data = factories.learningResources.resource() | ||
setMockResponse.get(urls.learningResources.details({ id: 1 }), data) | ||
|
||
renderHook(() => useLearningResourcesDetail(1), { wrapper }) | ||
|
||
renderHook(usePrefetchWarnings, { | ||
wrapper, | ||
initialProps: { queryClient }, | ||
}) | ||
|
||
expect(console.info).toHaveBeenCalledWith( | ||
"The following queries were requested in first render but not prefetched.", | ||
"If these queries are user-specific, they cannot be prefetched - responses are cached on public CDN.", | ||
"Otherwise, consider fetching on the server with prefetch:", | ||
) | ||
expect(console.table).toHaveBeenCalledWith( | ||
[ | ||
expect.objectContaining({ | ||
disabled: false, | ||
initialStatus: "loading", | ||
key: learningResourcesKeyFactory.detail(1).queryKey, | ||
observerCount: 1, | ||
}), | ||
], | ||
["hash", "initialStatus", "status", "observerCount", "disabled"], | ||
) | ||
}) | ||
|
||
it("Ignores exempted queries requested on the client that have not been prefetched", async () => { | ||
const { wrapper, queryClient } = setupReactQueryTest() | ||
|
||
const data = factories.learningResources.resource() | ||
setMockResponse.get(urls.learningResources.details({ id: 1 }), data) | ||
|
||
renderHook(() => useLearningResourcesDetail(1), { wrapper }) | ||
|
||
renderHook(usePrefetchWarnings, { | ||
wrapper, | ||
initialProps: { | ||
queryClient, | ||
exemptions: [learningResourcesKeyFactory.detail(1).queryKey], | ||
}, | ||
}) | ||
|
||
expect(console.info).not.toHaveBeenCalled() | ||
expect(console.table).not.toHaveBeenCalled() | ||
}) | ||
|
||
it("Warns for queries prefetched on the server but not requested on the client", async () => { | ||
const { wrapper, queryClient } = setupReactQueryTest() | ||
|
||
const data = factories.learningResources.resource() | ||
setMockResponse.get(urls.learningResources.details({ id: 1 }), data) | ||
|
||
// Emulate server prefetch | ||
const { unmount } = renderHook( | ||
() => | ||
useQuery({ | ||
...learningResourcesKeyFactory.detail(1), | ||
initialData: data, | ||
}), | ||
{ wrapper }, | ||
) | ||
|
||
// Removes observer | ||
unmount() | ||
|
||
renderHook(usePrefetchWarnings, { | ||
wrapper, | ||
initialProps: { queryClient }, | ||
}) | ||
|
||
expect(console.info).toHaveBeenCalledWith( | ||
"The following queries were prefetched on the server but not accessed during initial render.", | ||
"If these queries are no longer in use they should removed from prefetch:", | ||
) | ||
expect(console.table).toHaveBeenCalledWith( | ||
[ | ||
{ | ||
disabled: false, | ||
hash: JSON.stringify(learningResourcesKeyFactory.detail(1).queryKey), | ||
initialStatus: "success", | ||
key: learningResourcesKeyFactory.detail(1).queryKey, | ||
observerCount: 0, | ||
status: "success", | ||
}, | ||
], | ||
["hash", "initialStatus", "status", "observerCount", "disabled"], | ||
) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import { useEffect } from "react" | ||
import type { Query, QueryClient, QueryKey } from "@tanstack/react-query" | ||
|
||
const logQueries = (...args: [...string[], Query[]]) => { | ||
const queries = args.pop() as Query[] | ||
console.info(...args) | ||
console.table( | ||
queries.map((query) => ({ | ||
key: query.queryKey, | ||
hash: query.queryHash, | ||
disabled: query.isDisabled(), | ||
initialStatus: query.initialState.status, | ||
status: query.state.status, | ||
observerCount: query.getObserversCount(), | ||
})), | ||
["hash", "initialStatus", "status", "observerCount", "disabled"], | ||
) | ||
} | ||
|
||
const PREFETCH_EXEMPT_QUERIES = [["userMe"]] | ||
|
||
/** | ||
* Call this as high as possible in render tree to detect query usage on | ||
* first render. | ||
*/ | ||
export const usePrefetchWarnings = ({ | ||
queryClient, | ||
exemptions = [], | ||
}: { | ||
queryClient: QueryClient | ||
/** | ||
* A list of query keys that should be exempted. | ||
* | ||
* NOTE: This uses react-query's hierarchical key matching, so exempting | ||
* ["a", { x: 1 }] will exempt | ||
* - ["a", { x: 1 }] | ||
* - ["a", { x: 1, y: 2 }] | ||
* - ["a", { x: 1, y: 2 }, ...any_other_entries] | ||
*/ | ||
exemptions?: QueryKey[] | ||
}) => { | ||
/** | ||
* NOTE: React renders components top-down, but effects run bottom-up, so | ||
* this effect will run after all child effects. | ||
*/ | ||
useEffect( | ||
() => { | ||
if (process.env.NODE_ENV === "production") { | ||
return | ||
} | ||
|
||
const cache = queryClient.getQueryCache() | ||
const queries = cache.getAll() | ||
|
||
const exempted = [...exemptions, ...PREFETCH_EXEMPT_QUERIES].map((key) => | ||
cache.find(key), | ||
) | ||
|
||
const potentialPrefetches = queries.filter( | ||
(query) => | ||
!exempted.includes(query) && | ||
query.initialState.status !== "success" && | ||
!query.isDisabled(), | ||
) | ||
|
||
if (potentialPrefetches.length > 0) { | ||
logQueries( | ||
"The following queries were requested in first render but not prefetched.", | ||
"If these queries are user-specific, they cannot be prefetched - responses are cached on public CDN.", | ||
"Otherwise, consider fetching on the server with prefetch:", | ||
potentialPrefetches, | ||
) | ||
} | ||
|
||
const unusedPrefetches = queries.filter( | ||
(query) => | ||
!exempted.includes(query) && | ||
query.initialState.status === "success" && | ||
query.getObserversCount() === 0 && | ||
!query.isDisabled(), | ||
) | ||
|
||
if (unusedPrefetches.length > 0) { | ||
logQueries( | ||
"The following queries were prefetched on the server but not accessed during initial render.", | ||
"If these queries are no longer in use they should removed from prefetch:", | ||
unusedPrefetches, | ||
) | ||
} | ||
}, | ||
// We only want to run this on initial render. | ||
// (Aside: queryClient should be a singleton anyway) | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
[], | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,27 @@ | ||
import React from "react" | ||
import { Metadata } from "next" | ||
|
||
import DepartmentListingPage from "@/app-pages/DepartmentListingPage/DepartmentListingPage" | ||
import { standardizeMetadata } from "@/common/metadata" | ||
import { Hydrate } from "@tanstack/react-query" | ||
import { learningResourcesKeyFactory } from "api/hooks/learningResources" | ||
import { channelsKeyFactory } from "api/hooks/channels" | ||
import { prefetch } from "api/ssr/prefetch" | ||
|
||
export const metadata: Metadata = standardizeMetadata({ | ||
title: "Departments", | ||
}) | ||
|
||
import DepartmentListingPage from "@/app-pages/DepartmentListingPage/DepartmentListingPage" | ||
const Page: React.FC = async () => { | ||
const dehydratedState = await prefetch([ | ||
channelsKeyFactory.countsByType("department"), | ||
learningResourcesKeyFactory.schools(), | ||
]) | ||
|
||
const Page: React.FC = () => { | ||
return <DepartmentListingPage /> | ||
return ( | ||
<Hydrate state={dehydratedState}> | ||
<DepartmentListingPage /> | ||
</Hydrate> | ||
) | ||
} | ||
|
||
export default Page |
Oops, something went wrong.