diff --git a/frontends/api/src/ssr/useMounted.ts b/frontends/api/src/ssr/useMounted.ts
new file mode 100644
index 0000000000..809b8d412f
--- /dev/null
+++ b/frontends/api/src/ssr/useMounted.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react"
+
+/*
+ * Intended for cases where the client content would otherwise be different
+ * from the server content on the first render pass in the browser and therefore
+ * cause a hydration mismatch error. We've seen this for example when lazy loading
+ * components with next/dynamic, whuch produces a race condition with client only /
+ * session based API responses.
+ *
+ * https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content
+ */
+export const useMounted = () => {
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+
+ return () => {
+ setMounted(false)
+ }
+ }, [])
+
+ return mounted
+}
diff --git a/frontends/api/src/ssr/usePrefetchWarnings.test.ts b/frontends/api/src/ssr/usePrefetchWarnings.test.ts
index 1b937459e1..1f92c99e29 100644
--- a/frontends/api/src/ssr/usePrefetchWarnings.test.ts
+++ b/frontends/api/src/ssr/usePrefetchWarnings.test.ts
@@ -1,4 +1,4 @@
-import { renderHook } from "@testing-library/react"
+import { renderHook, waitFor } from "@testing-library/react"
import { useQuery } from "@tanstack/react-query"
import { usePrefetchWarnings } from "./usePrefetchWarnings"
import { setupReactQueryTest } from "../hooks/test-utils"
@@ -35,6 +35,7 @@ describe("SSR prefetch warnings", () => {
initialProps: { queryClient },
})
+ await waitFor(() => expect(console.info).toHaveBeenCalledTimes(1))
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.",
@@ -97,6 +98,7 @@ describe("SSR prefetch warnings", () => {
initialProps: { queryClient },
})
+ await waitFor(() => expect(console.info).toHaveBeenCalledTimes(1))
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:",
diff --git a/frontends/api/src/ssr/usePrefetchWarnings.ts b/frontends/api/src/ssr/usePrefetchWarnings.ts
index 000d80261c..f198e282b2 100644
--- a/frontends/api/src/ssr/usePrefetchWarnings.ts
+++ b/frontends/api/src/ssr/usePrefetchWarnings.ts
@@ -1,5 +1,6 @@
-import { useEffect } from "react"
+import { useEffect, useState } from "react"
import type { Query, QueryClient, QueryKey } from "@tanstack/react-query"
+import { useMounted } from "./useMounted"
const logQueries = (...args: [...string[], Query[]]) => {
const queries = args.pop() as Query[]
@@ -17,7 +18,13 @@ const logQueries = (...args: [...string[], Query[]]) => {
)
}
-const PREFETCH_EXEMPT_QUERIES = [["userMe"]]
+const PREFETCH_EXEMPT_QUERIES = [
+ ["userMe"],
+ ["userLists", "membershipList", "membershipList"],
+ ["learningPaths", "membershipList", "membershipList"],
+]
+
+const RETRIES = process.env.JEST_WORKER_ID ? 1 : 10
/**
* Call this as high as possible in render tree to detect query usage on
@@ -39,13 +46,23 @@ export const usePrefetchWarnings = ({
*/
exemptions?: QueryKey[]
}) => {
+ const mounted = useMounted()
+ const [count, setCount] = useState(0)
+ const [potentialWarnings, setPotentialWarnings] = useState(true)
+
+ useEffect(() => {
+ if ((potentialWarnings && count < RETRIES) || count === RETRIES - 1) {
+ setTimeout(() => setCount(count + 1), 250)
+ }
+ }, [count, potentialWarnings])
+
/**
* 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") {
+ if (process.env.NODE_ENV === "production" || !mounted) {
return
}
@@ -63,7 +80,7 @@ export const usePrefetchWarnings = ({
!query.isDisabled(),
)
- if (potentialPrefetches.length > 0) {
+ if (potentialPrefetches.length > 0 && count === RETRIES) {
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.",
@@ -80,17 +97,21 @@ export const usePrefetchWarnings = ({
!query.isDisabled(),
)
- if (unusedPrefetches.length > 0) {
+ if (unusedPrefetches.length > 0 && count === RETRIES) {
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,
)
}
+
+ setPotentialWarnings(
+ potentialPrefetches.length > 0 || unusedPrefetches.length > 0,
+ )
},
// We only want to run this on initial render.
// (Aside: queryClient should be a singleton anyway)
// eslint-disable-next-line react-hooks/exhaustive-deps
- [],
+ [mounted, count],
)
}
diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts
index 1195a262ae..c518ea303f 100644
--- a/frontends/jest-shared-setup.ts
+++ b/frontends/jest-shared-setup.ts
@@ -5,6 +5,7 @@ import { configure } from "@testing-library/react"
import { resetAllWhenMocks } from "jest-when"
import * as matchers from "jest-extended"
import { mockRouter } from "ol-test-utilities/mocks/nextNavigation"
+import preloadAll from "jest-next-dynamic-ts"
expect.extend(matchers)
@@ -85,6 +86,10 @@ jest.mock("next/navigation", () => {
}
})
+beforeAll(async () => {
+ await preloadAll()
+})
+
afterEach(() => {
/**
* Clear all mock call counts between tests.
diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx
index 562e7d8e47..921b14f53d 100644
--- a/frontends/main/src/app-pages/HomePage/HomePage.tsx
+++ b/frontends/main/src/app-pages/HomePage/HomePage.tsx
@@ -1,16 +1,42 @@
"use client"
-import React from "react"
+import React, { Suspense } from "react"
import { Container, styled, theme } from "ol-components"
-import HeroSearch from "@/page-components/HeroSearch/HeroSearch"
-import BrowseTopicsSection from "./BrowseTopicsSection"
-import NewsEventsSection from "./NewsEventsSection"
-import TestimonialsSection from "./TestimonialsSection"
-import ResourceCarousel from "@/page-components/ResourceCarousel/ResourceCarousel"
-import PersonalizeSection from "./PersonalizeSection"
import * as carousels from "./carousels"
import dynamic from "next/dynamic"
+const HeroSearch = dynamic(
+ () => import("@/page-components/HeroSearch/HeroSearch"),
+ { ssr: true },
+)
+
+const TestimonialsSection = dynamic(() => import("./TestimonialsSection"), {
+ ssr: true,
+})
+
+const ResourceCarousel = dynamic(
+ () => import("@/page-components/ResourceCarousel/ResourceCarousel"),
+ { ssr: true },
+)
+
+const PersonalizeSection = dynamic(() => import("./PersonalizeSection"), {
+ ssr: true,
+})
+
+const BrowseTopicsSection = dynamic(() => import("./BrowseTopicsSection"), {
+ ssr: true,
+})
+
+const NewsEventsSection = dynamic(() => import("./NewsEventsSection"), {
+ ssr: true,
+})
+
+const LearningResourceDrawer = dynamic(
+ () =>
+ import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"),
+ { ssr: false },
+)
+
const FullWidthBackground = styled.div({
background: "linear-gradient(0deg, #FFF 0%, #E9ECEF 100%);",
paddingBottom: "80px",
@@ -44,11 +70,6 @@ const StyledContainer = styled(Container)({
},
})
-const LearningResourceDrawer = dynamic(
- () =>
- import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"),
-)
-
const HomePage: React.FC<{ heroImageIndex: number }> = ({ heroImageIndex }) => {
return (
<>
@@ -57,21 +78,25 @@ const HomePage: React.FC<{ heroImageIndex: number }> = ({ heroImageIndex }) => {
-
+
+
+
diff --git a/frontends/main/src/page-components/Header/Header.tsx b/frontends/main/src/page-components/Header/Header.tsx
index 93ef5bcdb9..87760096ed 100644
--- a/frontends/main/src/page-components/Header/Header.tsx
+++ b/frontends/main/src/page-components/Header/Header.tsx
@@ -1,14 +1,9 @@
"use client"
import React, { FunctionComponent } from "react"
+import dynamic from "next/dynamic"
import type { NavData } from "ol-components"
-import {
- styled,
- AppBar,
- NavDrawer,
- Toolbar,
- ActionButtonLink,
-} from "ol-components"
+import { styled, AppBar, Toolbar, ActionButtonLink } from "ol-components"
import {
RiSearch2Line,
RiPencilRulerLine,
@@ -25,7 +20,6 @@ import {
} from "@remixicon/react"
import { useToggle } from "ol-utilities"
import MITLogoLink from "@/components/MITLogoLink/MITLogoLink"
-import UserMenu from "./UserMenu"
import { MenuButton } from "./MenuButton"
import {
DEPARTMENTS,
@@ -43,6 +37,13 @@ import {
} from "@/common/urls"
import { useUserMe } from "api/hooks/user"
+const NavDrawer = dynamic(
+ () => import("ol-components").then((mod) => mod.NavDrawer),
+ { ssr: false },
+)
+
+const UserMenu = dynamic(() => import("./UserMenu"), { ssr: false })
+
const Bar = styled(AppBar)(({ theme }) => ({
padding: "16px 8px",
backgroundColor: theme.custom.colors.navGray,
diff --git a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
index cba791275e..5652c0c6c8 100644
--- a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
+++ b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
@@ -12,6 +12,7 @@ import {
} from "../Dialogs/AddToListDialog"
import { useResourceDrawerHref } from "../LearningResourceDrawer/useResourceDrawerHref"
import { useUserMe } from "api/hooks/user"
+import { useMounted } from "api/ssr/useMounted"
import { LearningResource } from "api"
import { SignupPopover } from "../SignupPopover/SignupPopover"
import { useIsUserListMember } from "api/hooks/userLists"
@@ -100,6 +101,7 @@ const ResourceCard: React.FC = ({
inLearningPath,
onClick,
} = useResourceCard(resource)
+ const mounted = useMounted()
const CardComponent =
list && condensed
? LearningResourceListCardCondensed
@@ -112,8 +114,8 @@ const ResourceCard: React.FC = ({
onClick={onClick}
resource={resource}
href={resource ? getDrawerHref(resource.id) : undefined}
- onAddToLearningPathClick={handleAddToLearningPathClick}
- onAddToUserListClick={handleAddToUserListClick}
+ onAddToLearningPathClick={mounted ? handleAddToLearningPathClick : null}
+ onAddToUserListClick={mounted ? handleAddToUserListClick : null}
inUserList={inUserList}
inLearningPath={inLearningPath}
{...others}
diff --git a/frontends/package.json b/frontends/package.json
index d59f633dcd..391670885c 100644
--- a/frontends/package.json
+++ b/frontends/package.json
@@ -63,6 +63,7 @@
"jest-environment-jsdom": "^29.5.0",
"jest-extended": "^4.0.2",
"jest-fail-on-console": "^3.3.1",
+ "jest-next-dynamic-ts": "^0.1.1",
"jest-watch-typeahead": "^2.2.2",
"jest-when": "^3.6.0",
"postcss-styled-syntax": "^0.7.0",
diff --git a/yarn.lock b/yarn.lock
index d377da5b01..c2e40d6dae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6730,8 +6730,8 @@ __metadata:
linkType: hard
"ai@npm:^4.0.13":
- version: 4.0.36
- resolution: "ai@npm:4.0.36"
+ version: 4.0.38
+ resolution: "ai@npm:4.0.38"
dependencies:
"@ai-sdk/provider": "npm:1.0.4"
"@ai-sdk/provider-utils": "npm:2.0.7"
@@ -6747,7 +6747,7 @@ __metadata:
optional: true
zod:
optional: true
- checksum: 10/01e0ca0ae86a7ad4dd2bfbb28b482f2478a37d4f8cb0db002968ce4b1756c4edc9450d706e2c8755f8cbcc66015ec30b4bfb7c71e35cb93a9eb4f8e3e6ff3625
+ checksum: 10/6baa0020dd7d8d6607cc478c5251b16f987a9f80e2f1966d003eaf376850d698996a1d806aa0e07cfb8d90495f60247e6de1d84eefdbef2363f823e64c15f594
languageName: node
linkType: hard
@@ -10475,6 +10475,7 @@ __metadata:
jest-environment-jsdom: "npm:^29.5.0"
jest-extended: "npm:^4.0.2"
jest-fail-on-console: "npm:^3.3.1"
+ jest-next-dynamic-ts: "npm:^0.1.1"
jest-watch-typeahead: "npm:^2.2.2"
jest-when: "npm:^3.6.0"
postcss-styled-syntax: "npm:^0.7.0"
@@ -12304,6 +12305,13 @@ __metadata:
languageName: node
linkType: hard
+"jest-next-dynamic-ts@npm:^0.1.1":
+ version: 0.1.1
+ resolution: "jest-next-dynamic-ts@npm:0.1.1"
+ checksum: 10/e8d70c827e46f8f5053559fdad193a84805a241ab888cdbe4905c05c1635302eb4a0d4255327e48b144f338e62f88d3ed09c8dc6df56f79f10eca7968a099850
+ languageName: node
+ linkType: hard
+
"jest-pnp-resolver@npm:^1.2.2":
version: 1.2.3
resolution: "jest-pnp-resolver@npm:1.2.3"