diff --git a/frontends/api/package.json b/frontends/api/package.json
index ecaa7c61c2..12acd63404 100644
--- a/frontends/api/package.json
+++ b/frontends/api/package.json
@@ -10,6 +10,7 @@
"./v1": "./src/generated/v1/api.ts",
"./hooks/*": "./src/hooks/*/index.ts",
"./constants": "./src/common/constants.ts",
+ "./ssr/*": "./src/ssr/*.ts",
"./test-utils/factories": "./src/test-utils/factories/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
diff --git a/frontends/api/src/hooks/channels/index.ts b/frontends/api/src/hooks/channels/index.ts
index b86f798c58..549e77778b 100644
--- a/frontends/api/src/hooks/channels/index.ts
+++ b/frontends/api/src/hooks/channels/index.ts
@@ -27,6 +27,7 @@ const useChannelDetail = (channelType: string, channelName: string) => {
...channels.detailByType(channelType, channelName),
})
}
+
const useChannelCounts = (channelType: string) => {
return useQuery({
...channels.countsByType(channelType),
@@ -54,4 +55,5 @@ export {
useChannelsList,
useChannelPartialUpdate,
useChannelCounts,
+ channels as channelsKeyFactory,
}
diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts
index 7633c806f8..6c2ea60ef3 100644
--- a/frontends/api/src/hooks/learningResources/index.ts
+++ b/frontends/api/src/hooks/learningResources/index.ts
@@ -500,13 +500,6 @@ const useSchoolsList = () => {
return useQuery(learningResources.schools())
}
-/*
- * Not intended to be imported except for special cases.
- * It's used in the ResourceCarousel to dynamically build a single useQueries hook
- * from config because a React component cannot conditionally call hooks during renders.
- */
-export { default as learningResourcesKeyFactory } from "./keyFactory"
-
export {
useLearningResourcesList,
useFeaturedLearningResourcesList,
@@ -538,4 +531,5 @@ export {
useListItemMove,
usePlatformsList,
useSchoolsList,
+ learningResources as learningResourcesKeyFactory,
}
diff --git a/frontends/api/src/hooks/newsEvents/index.ts b/frontends/api/src/hooks/newsEvents/index.ts
index 0e14708dba..b6f0d9b26f 100644
--- a/frontends/api/src/hooks/newsEvents/index.ts
+++ b/frontends/api/src/hooks/newsEvents/index.ts
@@ -15,4 +15,9 @@ const useNewsEventsDetail = (id: number) => {
return useQuery(newsEvents.detail(id))
}
-export { useNewsEventsList, useNewsEventsDetail, NewsEventsListFeedTypeEnum }
+export {
+ useNewsEventsList,
+ useNewsEventsDetail,
+ NewsEventsListFeedTypeEnum,
+ newsEvents as newsEventsKeyFactory,
+}
diff --git a/frontends/api/src/hooks/testimonials/index.ts b/frontends/api/src/hooks/testimonials/index.ts
index 2ee1c6f4c5..da98b49afc 100644
--- a/frontends/api/src/hooks/testimonials/index.ts
+++ b/frontends/api/src/hooks/testimonials/index.ts
@@ -23,4 +23,8 @@ const useTestimonialDetail = (id: number | undefined) => {
})
}
-export { useTestimonialDetail, useTestimonialList }
+export {
+ useTestimonialDetail,
+ useTestimonialList,
+ testimonials as testimonialsKeyFactory,
+}
diff --git a/frontends/api/src/hooks/widget_lists/index.ts b/frontends/api/src/hooks/widget_lists/index.ts
index 2ac9aff1f4..1c8c2c42d2 100644
--- a/frontends/api/src/hooks/widget_lists/index.ts
+++ b/frontends/api/src/hooks/widget_lists/index.ts
@@ -3,8 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { widgetListsApi } from "../../clients"
import widgetLists from "./keyFactory"
import { WidgetInstance } from "api/v0"
+
/**
- * Query is diabled if id is undefined.
+ * Query is disabled if id is undefined.
*/
const useWidgetList = (id: number | undefined) => {
return useQuery({
diff --git a/frontends/api/src/ssr/prefetch.ts b/frontends/api/src/ssr/prefetch.ts
new file mode 100644
index 0000000000..6078fcafec
--- /dev/null
+++ b/frontends/api/src/ssr/prefetch.ts
@@ -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)
+}
diff --git a/frontends/api/src/ssr/usePrefetchWarnings.test.ts b/frontends/api/src/ssr/usePrefetchWarnings.test.ts
new file mode 100644
index 0000000000..ab34473ba8
--- /dev/null
+++ b/frontends/api/src/ssr/usePrefetchWarnings.test.ts
@@ -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"],
+ )
+ })
+})
diff --git a/frontends/api/src/ssr/usePrefetchWarnings.ts b/frontends/api/src/ssr/usePrefetchWarnings.ts
new file mode 100644
index 0000000000..000d80261c
--- /dev/null
+++ b/frontends/api/src/ssr/usePrefetchWarnings.ts
@@ -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
+ [],
+ )
+}
diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx
index 475e53efd7..ac4e7891a0 100644
--- a/frontends/main/src/app-pages/HomePage/HomePage.tsx
+++ b/frontends/main/src/app-pages/HomePage/HomePage.tsx
@@ -1,6 +1,6 @@
"use client"
-import React, { Suspense } from "react"
+import React from "react"
import { Container, styled, theme } from "ol-components"
import HeroSearch from "@/page-components/HeroSearch/HeroSearch"
import BrowseTopicsSection from "./BrowseTopicsSection"
@@ -52,25 +52,21 @@ const HomePage: React.FC = () => {
-
-
-
+
diff --git a/frontends/main/src/app/departments/page.tsx b/frontends/main/src/app/departments/page.tsx
index 41364044ee..a9b5133e9f 100644
--- a/frontends/main/src/app/departments/page.tsx
+++ b/frontends/main/src/app/departments/page.tsx
@@ -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
+ return (
+
+
+
+ )
}
export default Page
diff --git a/frontends/main/src/app/page.tsx b/frontends/main/src/app/page.tsx
index 34dd27b718..ae920e6784 100644
--- a/frontends/main/src/app/page.tsx
+++ b/frontends/main/src/app/page.tsx
@@ -2,6 +2,13 @@ import React from "react"
import type { Metadata } from "next"
import HomePage from "@/app-pages/HomePage/HomePage"
import { getMetadataAsync } from "@/common/metadata"
+import { Hydrate } from "@tanstack/react-query"
+import { testimonialsKeyFactory } from "api/hooks/testimonials"
+import {
+ NewsEventsListFeedTypeEnum,
+ newsEventsKeyFactory,
+} from "api/hooks/newsEvents"
+import { prefetch } from "api/ssr/prefetch"
type SearchParams = {
[key: string]: string | string[] | undefined
@@ -19,7 +26,25 @@ export async function generateMetadata({
}
const Page: React.FC = async () => {
- return
+ const dehydratedState = await prefetch([
+ testimonialsKeyFactory.list({ position: 1 }),
+ newsEventsKeyFactory.list({
+ feed_type: [NewsEventsListFeedTypeEnum.News],
+ limit: 6,
+ sortby: "-news_date",
+ }),
+ newsEventsKeyFactory.list({
+ feed_type: [NewsEventsListFeedTypeEnum.Events],
+ limit: 5,
+ sortby: "event_date",
+ }),
+ ])
+
+ return (
+
+
+
+ )
}
export default Page
diff --git a/frontends/main/src/app/providers.tsx b/frontends/main/src/app/providers.tsx
index 7c0506a702..ddd3a29456 100644
--- a/frontends/main/src/app/providers.tsx
+++ b/frontends/main/src/app/providers.tsx
@@ -6,10 +6,13 @@ import { QueryClientProvider } from "@tanstack/react-query"
import { ThemeProvider, NextJsAppRouterCacheProvider } from "ol-components"
import { Provider as NiceModalProvider } from "@ebay/nice-modal-react"
import ConfiguredPostHogProvider from "@/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider"
+import { usePrefetchWarnings } from "api/ssr/usePrefetchWarnings"
export default function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
+ usePrefetchWarnings({ queryClient })
+
return (