Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Release Notes
=============

Version 0.47.9
--------------

- don't use next_start_date for "anytime" courses & programs (#2670)
- Shanbady/fix subscription groups (#2664)
- Server side query retry and caching with request scoped QueryClient (#2596)
- alter org page header based on new requirements (#2659)
- Get rid of debug code (#2661)
- Eliminate build-time API calls, default to dynamic rendering (#2660)

Version 0.47.8 (Released October 29, 2025)
--------------

Expand Down
8 changes: 7 additions & 1 deletion docker-compose.apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ services:
command:
- |
yarn install --immutable
yarn watch
if [ "$${NODE_ENV}" = "production" ]; then
yarn build
echo "WARNING: Production preview will NOT re-render on file changes."
yarn preview
else
yarn watch
fi
ports:
- "8062:8062"
- "6006:6006"
Expand Down
11 changes: 11 additions & 0 deletions frontends/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ module.exports = {
message:
"The LearningResourceDrawer should be lazy loaded with dynamic import.",
},
{
group: ["api/clients"],
message:
"Direct import from 'api/clients' is not allowed. Use React Query hooks from 'api/hooks/*' instead for caching and error handling. In server components, use getServerQueryClient().fetchQuery(), passing the queryOptions.",
},
],
}),
// This rule is disabled in the default a11y config, but unclear why.
Expand Down Expand Up @@ -145,6 +150,12 @@ module.exports = {
message:
"Do not specify `fontFamily` manually. Prefer spreading `theme.typography.subtitle1` or similar. If using neue-haas-grotesk-text, this is ThemeProvider's default fontFamily.",
},
{
selector:
"FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.type!='CallExpression'], FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.callee.name!='safeGenerateMetadata']",
message:
"generateMetadata functions must return safeGenerateMetadata() to ensure proper error handling and fallback metadata.",
},
],
},
overrides: [
Expand Down
2 changes: 0 additions & 2 deletions frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
"sideEffects": false,
"exports": {
".": "./src/generated/v1/api.ts",
"./clients": "./src/clients.ts",
"./v0": "./src/generated/v0/api.ts",
"./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",
"./mitxonline": "./src/mitxonline/index.ts",
"./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts",
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts"
},
Expand Down
14 changes: 14 additions & 0 deletions frontends/api/src/hooks/learningResources/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
FeaturedApiFeaturedListRequest as FeaturedListParams,
LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
LearningResourcesSearchResponse,
LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest,
} from "../../generated/v1"
import { queryOptions } from "@tanstack/react-query"
import { hasPosition, randomizeGroups } from "./util"
Expand Down Expand Up @@ -46,6 +47,11 @@ const learningResourceKeys = {
...learningResourceKeys.listsRoot(),
params,
],
summaryListRoot: () => [...learningResourceKeys.root, "summaryList"],
summaryList: (params: LearningResourcesSummaryListRequest) => [
...learningResourceKeys.summaryListRoot(),
params,
],
// detail
detailsRoot: () => [...learningResourceKeys.root, "detail"],
detail: (id: number) => [...learningResourceKeys.detailsRoot(), id],
Expand Down Expand Up @@ -150,6 +156,14 @@ const learningResourceQueries = {
results: res.data.results.map(clearListMemberships),
})),
}),
summaryList: (params: LearningResourcesSummaryListRequest) =>
queryOptions({
queryKey: learningResourceKeys.summaryList(params),
queryFn: () =>
learningResourcesApi
.learningResourcesSummaryList(params)
.then((res) => res.data),
}),
featured: (params: FeaturedListParams = {}) =>
queryOptions({
queryKey: learningResourceKeys.featured(params),
Expand Down
1 change: 0 additions & 1 deletion frontends/api/src/mitxonline/index.ts

This file was deleted.

70 changes: 15 additions & 55 deletions frontends/api/src/ssr/prefetch.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,31 @@
import { QueryClient, dehydrate } from "@tanstack/react-query"
import { dehydrate } from "@tanstack/react-query"
import { getServerQueryClient } from "./serverQueryClient"
import type { Query } from "@tanstack/react-query"

const MAX_RETRIES = 3
const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422]

type MaybeHasStatus = {
response?: {
status?: number
}
}

const getPrefetchQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
/**
* React Query's default retry logic is only active in the browser.
* Here we explicitly configure it to retry MAX_RETRIES times on
* the server, with an exclusion list of statuses that we expect not
* to succeed on retry.
*
* Includes status undefined as we want to retry on network errors
*/
retry: (failureCount, error) => {
const status = (error as MaybeHasStatus)?.response?.status
const isNetworkError = status === undefined || status === 0

if (isNetworkError || !NO_RETRY_CODES.includes(status)) {
return failureCount < MAX_RETRIES
}
return false
},

/**
* By default, React Query gradually applies a backoff delay, though it is
* preferable that we do not significantly delay initial page renders (or
* indeed pages that are Statically Rendered during the build process) and
* instead allow the request to fail quickly so it can be subsequently
* fetched on the client.
*/
retryDelay: 1000,
},
},
})
}

/* Utility to avoid repetition in server components
/**
* Utility to avoid repetition in server components
* Optionally pass the queryClient returned from a previous prefetch
* where queries are dependent on previous results
*/
export const prefetch = async (
queries: (Query | unknown)[],

/**
* The SSR QueryClient is transient - it is created only for prefetch
* while API requests are made to server render the page and discarded
* as the dehydrated state is produced and sent to the client.
* Unless passed, the SSR QueryClient uses React's cache() for reuse for the duration of the request.
*
* Create a new query client if one is not provided.
* The QueryClient is garbage collected once the dehydrated state is produced and
* sent to the client and the request is complete.
*/
queryClient = getPrefetchQueryClient(),
queryClient = getServerQueryClient(),
) => {
await Promise.all(
queries
.filter(Boolean)
.map((query) => queryClient.prefetchQuery(query as Query)),
queries.filter(Boolean).map((query) => {
return queryClient.prefetchQuery(query as Query)
}),
)

return { dehydratedState: dehydrate(queryClient), queryClient }
return {
dehydratedState: dehydrate(queryClient),
queryClient,
}
}
76 changes: 76 additions & 0 deletions frontends/api/src/ssr/serverQueryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { cache } from "react"
import { QueryClient } from "@tanstack/react-query"
import { AxiosError } from "axios"

const MAX_RETRIES = 3
const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422]

/**
* Get or create a server-side QueryClient for consistent retry behavior.
* The server QueryClient should be used for all server-side API calls.
*
* Uses React's cache() to ensure the same QueryClient instance is reused
* throughout a single HTTP request, enabling:
*
* - Server API calls share the same QueryClient:
* - Prefetch runs in page server components
* - generateMetadata()
* - No duplicate API calls within the same request
* - Automatic cleanup when the request completes
* - Isolation between different HTTP requests
*
* The QueryClientProvider runs (during SSR) in a separate render pass as it's a
* client component and so the instance is not reused. On the server this does not
* make API calls and only sets up the hydration boundary and registers hooks in
* readiness for the dehydrated state to be sent to the client.
*/
const getServerQueryClient = cache(() => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
/**
* React Query's default retry logic is only active in the browser.
* Here we explicitly configure it to retry MAX_RETRIES times on
* the server, with an exclusion list of statuses that we expect not
* to succeed on retry.
*
* Includes status undefined as we want to retry on network errors
*/
retry: (failureCount, error) => {
const axiosError = error as AxiosError
console.info("Retrying failed request", {
failureCount,
error: {
message: axiosError.message,
name: axiosError.name,
status: axiosError?.status,
code: axiosError.code,
method: axiosError.request?.method,
url: axiosError.request?.url,
},
})
const status = (error as AxiosError)?.response?.status
const isNetworkError = status === undefined || status === 0

if (isNetworkError || !NO_RETRY_CODES.includes(status)) {
return failureCount < MAX_RETRIES
}
return false
},

/**
* By default, React Query gradually applies a backoff delay, though it is
* preferable that we do not significantly delay initial page renders (or
* indeed pages that are Statically Rendered during the build process) and
* instead allow the request to fail quickly so it can be subsequently
* fetched on the client.
*/
retryDelay: 1000,
},
},
})

return queryClient
})

export { getServerQueryClient }
1 change: 1 addition & 0 deletions frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@sentry/nextjs": "^10.0.0",
"@tanstack/react-query": "^5.66",
"api": "workspace:*",
"async_hooks": "^1.0.0",
"classnames": "^2.5.1",
"formik": "^2.4.6",
"iso-639-1": "^3.1.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const makeProgram = factories.programs.program
const makeProgramCollection = factories.programs.programCollection
const makeCourseEnrollment = factories.enrollment.courseEnrollment
const makeGrade = factories.enrollment.grade
const makeContract = factories.contracts.contract

const dashboardCourse: PartialFactory<DashboardCourse> = (...overrides) => {
return mergeOverrides<DashboardCourse>(
Expand Down Expand Up @@ -127,6 +128,11 @@ const setupEnrollments = (includeExpired: boolean) => {
const setupProgramsAndCourses = () => {
const user = u.factories.user.user()
const orgX = factories.organizations.organization({ name: "Org X" })
const contract = makeContract({
organization: orgX.id,
name: "Org X Contract",
})
orgX.contracts = [contract]
const mitxOnlineUser = factories.user.user({ b2b_organizations: [orgX] })
setMockResponse.get(u.urls.userMe.get(), user)
setMockResponse.get(urls.userMe.get(), mitxOnlineUser)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ describe("OrganizationContent", () => {
renderWithProviders(<OrganizationContent orgSlug={orgX.slug} />)

await screen.findByRole("heading", {
name: `Your ${orgX.name} Home`,
name: orgX.name,
})
await screen.findByText(orgX.contracts[0].name)

const programAHeader = await screen.findByText(programA.title)
const programBHeader = await screen.findByText(programB.title)
Expand Down Expand Up @@ -300,8 +301,9 @@ describe("OrganizationContent", () => {

// Wait for the header to appear
await screen.findByRole("heading", {
name: `Your ${orgX.name} Home`,
name: orgX.name,
})
await screen.findByText(orgX.contracts[0].name)

// Since there are no programs or collections, no program/collection components should be rendered
const programs = screen.queryAllByTestId("org-program-root")
Expand Down Expand Up @@ -331,8 +333,9 @@ describe("OrganizationContent", () => {

// Wait for the header to appear
await screen.findByRole("heading", {
name: `Your ${orgX.name} Home`,
name: orgX.name,
})
await screen.findByText(orgX.contracts[0].name)

// Should have no collections
const collections = screen.queryAllByTestId("org-program-collection-root")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ const OrganizationHeader: React.FC<{ org?: OrganizationPage }> = ({ org }) => {
</ImageContainer>
<Stack gap="8px">
<Typography variant="h3" component="h1">
Your {org?.name} Home
{org?.name}
</Typography>
<Typography variant="body1">MIT courses for {org?.name}</Typography>
{/* For now we will use the first contract name until we refactor this to be based on contracts / offerings */}
<Typography variant="body1">{org?.contracts[0]?.name}</Typography>
</Stack>
</HeaderRoot>
)
Expand Down
Loading
Loading