diff --git a/docs/decisions/0015-page-titles-via-helmet.rst b/docs/decisions/0015-page-titles-via-helmet.rst new file mode 100644 index 00000000..09d4cda5 --- /dev/null +++ b/docs/decisions/0015-page-titles-via-helmet.rst @@ -0,0 +1,120 @@ +#################################### +Apps set page titles via react-helmet +#################################### + +Status +====== + +Proposed + + +Context +======= + +A frontend-base site renders all of its apps under a single ``index.html`` +document with a static ```` element. Once the shell is loaded, the +document title only changes if something inside the React tree explicitly +updates it. + +``frontend-app-authn`` sets a title on each route-level page using +``<Helmet>`` from ``react-helmet``, with a localized message of the form +``{Page Name} | {siteName}``:: + + <Helmet> + <title> + {formatMessage(messages['login.page.title'], { siteName: getSiteConfig().siteName })} + + + +The last title set by any app sticks until it is replaced; and currently, Authn +is the only app that does this. In practice, after a user signs in, the +browser tab continues to read "Login | " while they sit on the learner +dashboard. This is a UX regression. + +A consistent, app-owned pattern is needed so that every route updates the +title and apps don't silently inherit each other's state. + + +Decision +======== + +Every route-level page component in an app is responsible for setting the +document title. Apps do this with ```` from ``react-helmet``, +following the pattern already established in Authn: + +#. The page component renders a ```` block containing a single + ```` element. +#. The title text comes from a localized message with the id pattern + ``{page}.page.title`` and a default of ``{Page Name} | {siteName}``. +#. ``siteName`` is passed as an i18n parameter, sourced from + ``getSiteConfig().siteName``, so localizers can re-order the segments + without code changes. +#. The message lives in the same app, next to the page component. + +A "route-level page component" means the component a route renders directly +(``LoginPage``, ``LearnerDashboard``, ``InstructorDashboard``, etc.), not +shared layouts, slots, or nested widgets. Setting the title at the route +level keeps ownership unambiguous: exactly one component per visible page +claims the title. + +frontend-base does not own the title and does not re-export Helmet. Each app +that needs to set a title declares ``react-helmet`` as a dependency. + +The static ``<title>`` in the site's ``index.html`` remains the fallback for +the brief period before the React tree mounts. The operator should hard-code +it to the site name so the fallback is sensible if a page somehow fails to set +its own. + + +Consequences +============ + +Every app that renders user-visible pages adds ``react-helmet`` as a +dependency (if it doesn't already have one) and ``<Helmet>`` blocks to each +route-level page component, plus a ``{page}.page.title`` message per page. + +Pages with dynamic titles (e.g., a course outline page that should read +"<Course Name> | <site>") still fit the pattern: the page component renders +``<Helmet>`` after its data is available, with the dynamic value passed +through ``formatMessage``. Until the data resolves, the previous page's +title persists; that is acceptable for the short data-loading window and is +no worse than the current behavior. + +Because every app uses the same ``react-helmet`` instance under the hood and +Helmet's last-mount-wins semantics are well-defined, two pages cannot fight +over the title within a single navigation. + + +Rejected alternatives +===================== + +A ``usePageTitle()`` hook in frontend-base +------------------------------------------ + +frontend-base could export a hook that wraps ``react-helmet`` (or mutates +``document.title`` directly) so that apps don't import Helmet themselves. +This was rejected because the wrapper adds an API surface for something +Helmet already does well, and it would force a refactor of authn's existing +usage with no behavioral benefit. If many more apps adopt the pattern and a +wrapper proves valuable, we can revisit. + +Setting the title centrally in the shell from route metadata +------------------------------------------------------------ + +The shell could read a ``title`` field from each ``RoleRouteObject`` and +apply it on navigation. This was rejected because titles often depend on +data only available inside the page component after fetching (course names, +user names, dashboard counts). A static metadata field can't express that, +and a function-of-loader-data field re-creates the page component's +responsibilities one layer up. + +Standardizing on ``react-helmet-async`` +--------------------------------------- + +``react-helmet-async`` is generally recommended over ``react-helmet`` for +React 18+ projects: it avoids ``act()`` warnings, supports streaming SSR, and +is actively maintained. We chose ``react-helmet`` for this ADR because it +matches authn's current usage and frontend-base does not currently SSR. +Standardizing on ``react-helmet-async`` across all apps is a worthwhile +follow-up but does not need to block this decision; the pattern in this ADR +applies identically to either library. diff --git a/docs/how_tos/migrate-frontend-app.md b/docs/how_tos/migrate-frontend-app.md index 124c320f..1947ffb6 100644 --- a/docs/how_tos/migrate-frontend-app.md +++ b/docs/how_tos/migrate-frontend-app.md @@ -886,6 +886,65 @@ This may require a little interpretation. In spirit, the modules of your app ar These modules should be unopinionated about the path prefix where they are mounted. +Set the document title on every route-level page +================================================ + +A frontend-base site renders all of its apps inside a single `index.html`, so the document title only changes if a page explicitly updates it. If your app doesn't set a title, the browser tab keeps whatever the previously rendered app set. + +Each route-level page component must therefore set the document title using `<Helmet>` from `react-helmet`. In practice this is the small wrapper component the route lazy-loads (typically `Main`), not the inner content component or any shared layout, slot, or nested widget. The pattern is: + +1. Add `react-helmet` to the app's `dependencies` if it isn't there already. +2. Add a localized message per page, with the id pattern `{page}.page.title` and a default of `{Page Name} | {siteName}`. Don't reuse an existing on-page heading message (e.g., the h1/h2 "page title" used in the body): the document title needs `{siteName}` interpolation that would be wrong on a heading. +3. Render a `<Helmet>` block in the route entry that sets `<title>` from that message, passing `siteName` from `getSiteConfig().siteName` as an i18n parameter. + +```jsx +// src/Main.jsx — the component the route lazy-loads +import { CurrentAppProvider, getSiteConfig, useIntl } from '@openedx/frontend-base'; +import { Helmet } from 'react-helmet'; + +import { appId } from './constants'; +import messages from './messages'; +import Dashboard from './containers/Dashboard'; + +const Main = () => { + const { formatMessage } = useIntl(); + return ( + <CurrentAppProvider appId={appId}> + <Helmet> + <title> + {formatMessage(messages['learner.dashboard.page.title'], { + siteName: getSiteConfig().siteName, + })} + + + + + ); +}; + +export default Main; +``` + +```js +// src/messages.js +import { defineMessages } from '@openedx/frontend-base'; + +export default defineMessages({ + 'learner.dashboard.page.title': { + id: 'learner.dashboard.page.title', + defaultMessage: 'Dashboard | {siteName}', + description: 'document title for the learner dashboard', + }, +}); +``` + +If the app has nested child routes (for example, a parent route with tabbed sub-routes under it), set the title once at the parent route entry. Per-child titles are optional and follow the same pattern in each child component. + +Pages with dynamic titles (for example, a course page that reads `{Course Name} | {siteName}`) follow the same pattern: render `` once the data is available and pass the dynamic value through `formatMessage`. Until the data resolves, the previous page's title persists, which is acceptable for the brief loading window. + +See [ADR 0015](../decisions/0015-page-titles-via-helmet.rst) for the full rationale and rejected alternatives. + + Separate runtime styles from the dev harness ============================================