From 42e9cea904792e5126a82c98e8809501281cc070 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 5 Oct 2026 18:00:51 -0600 Subject: [PATCH 1/2] fix(mosaic): render the profile page title on the server The page title took its label from state that `Profile.Nav` set in a layout effect, so server-rendered HTML carried an empty heading until hydration. `Profile.Root` now reads the nav's children from its own children during render, so the title is in the first render and the SSR output. Co-Authored-By: Claude Fable 5.1 --- .changeset/profile-page-title-ssr.md | 2 ++ .../src/components/profile/profile.context.ts | 1 - .../components/profile/profile.ssr.test.tsx | 32 +++++++++++++++++++ .../mosaic/src/components/profile/profile.tsx | 32 +++++++++++++------ 4 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 .changeset/profile-page-title-ssr.md create mode 100644 packages/mosaic/src/components/profile/profile.ssr.test.tsx diff --git a/.changeset/profile-page-title-ssr.md b/.changeset/profile-page-title-ssr.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-page-title-ssr.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/profile/profile.context.ts b/packages/mosaic/src/components/profile/profile.context.ts index b1c92f779cc..8ed9d12eaf9 100644 --- a/packages/mosaic/src/components/profile/profile.context.ts +++ b/packages/mosaic/src/components/profile/profile.context.ts @@ -13,7 +13,6 @@ export interface ProfileContextValue { value: string; selectPage: (value: string) => void; navItems: React.ReactNode; - setNavItems: (items: React.ReactNode) => void; pageTitleId: string; pageTitleRef: React.MutableRefObject; navTriggerRef: React.MutableRefObject; diff --git a/packages/mosaic/src/components/profile/profile.ssr.test.tsx b/packages/mosaic/src/components/profile/profile.ssr.test.tsx new file mode 100644 index 00000000000..dba06fb5554 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile.ssr.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment node + +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { MosaicProvider } from '../../mosaic-provider'; +import { Profile } from './profile'; + +describe('Profile SSR', () => { + it('names the page in the page title heading on the server', () => { + const html = renderToString( + + + User profile + + Account + Security + + + Account page + Security page + + + , + ); + + const pageTitle = html.match(/]*class="[^"]*\bcl-profile-page-title\b[^"]*"[^>]*>(.*?)<\/h3>/)?.[1]; + expect(pageTitle).toContain('Security'); + expect(pageTitle).not.toContain('Account'); + }); +}); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 2c6c22d246a..ad113954be1 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -83,7 +83,7 @@ const Root = React.forwardRef(function Profile const pageTitleId = React.useId(); const pageTitleRef = React.useRef(null); const navTriggerRef = React.useRef(null); - const [navItems, setNavItems] = React.useState(null); + const navItems = navItemsOf(children); // Scoped to a layout so the replacement sheet never mounts open. const [navOpenIn, setNavOpenIn] = React.useState(null); const navOpen = navOpenIn === navLayout; @@ -111,7 +111,6 @@ const Root = React.forwardRef(function Profile value, selectPage, navItems, - setNavItems, pageTitleId, pageTitleRef, navTriggerRef, @@ -218,19 +217,16 @@ function NavBranding() { } /** - * Children are `Profile.NavItem`s only. Wide, they render as a tablist; compact, as the options of - * the page title's select, or a tablist in a sheet on a phone. + * A direct child of `Profile.Root`, with `Profile.NavItem`s as its only children. Wide, they render + * as a tablist; compact, as the options of the page title's select, or a tablist in a sheet on a + * phone. */ const Nav = React.forwardRef(function ProfileNav( { children, render, xstyle, ...rest }, ref, ) { - const { titleId, renderBranding, compact, navLayout, navOpen, closeNav, setNavItems, navTriggerRef, inline } = + const { titleId, renderBranding, compact, navLayout, navOpen, closeNav, navTriggerRef, inline } = useProfileContext('Profile.Nav'); - useSafeLayoutEffect(() => { - setNavItems(children); - }, [children, setNavItems]); - useSafeLayoutEffect(() => () => setNavItems(null), [setNavItems]); const element = useRender({ defaultTagName: 'nav', render, @@ -280,6 +276,24 @@ const Nav = React.forwardRef(function ProfileNav( ); }); +function navItemsOf(children: React.ReactNode): React.ReactNode { + for (const child of React.Children.toArray(children)) { + if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { + continue; + } + if (child.type === Nav) { + return child.props.children; + } + if (child.type === React.Fragment) { + const items = navItemsOf(child.props.children); + if (items !== null) { + return items; + } + } + } + return null; +} + export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { value: string; icon?: React.ReactNode; From 726355526db3c6843ef779b418bcb1eb5082f39a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 6 Oct 2026 14:48:10 -0600 Subject: [PATCH 2/2] refactor(mosaic): pass the profile page title as a prop `Profile.Content` takes the selected page's name as `pageTitle` instead of `Profile.Root` walking its children for the nav items. Co-Authored-By: Claude Opus 5.5 --- .../src/components/panel/panel.test.tsx | 4 +- .../src/components/profile/profile.context.ts | 1 + .../components/profile/profile.ssr.test.tsx | 2 +- .../src/components/profile/profile.test.tsx | 10 ++-- .../mosaic/src/components/profile/profile.tsx | 60 +++++++------------ .../organization-profile.view.tsx | 5 +- .../user-profile/user-profile.view.tsx | 5 +- .../src/stories/action-bar.stories.tsx | 2 +- .../src/stories/profile.component.mdx | 4 +- .../src/stories/profile.component.stories.tsx | 2 +- 10 files changed, 41 insertions(+), 54 deletions(-) diff --git a/packages/mosaic/src/components/panel/panel.test.tsx b/packages/mosaic/src/components/panel/panel.test.tsx index 8b28594a5b2..0f3f196dff5 100644 --- a/packages/mosaic/src/components/panel/panel.test.tsx +++ b/packages/mosaic/src/components/panel/panel.test.tsx @@ -71,7 +71,7 @@ describe('Panel', () => { Account - + @@ -94,7 +94,7 @@ describe('Panel', () => { Account - + Account diff --git a/packages/mosaic/src/components/profile/profile.context.ts b/packages/mosaic/src/components/profile/profile.context.ts index 8ed9d12eaf9..b1c92f779cc 100644 --- a/packages/mosaic/src/components/profile/profile.context.ts +++ b/packages/mosaic/src/components/profile/profile.context.ts @@ -13,6 +13,7 @@ export interface ProfileContextValue { value: string; selectPage: (value: string) => void; navItems: React.ReactNode; + setNavItems: (items: React.ReactNode) => void; pageTitleId: string; pageTitleRef: React.MutableRefObject; navTriggerRef: React.MutableRefObject; diff --git a/packages/mosaic/src/components/profile/profile.ssr.test.tsx b/packages/mosaic/src/components/profile/profile.ssr.test.tsx index dba06fb5554..14ac0e6e101 100644 --- a/packages/mosaic/src/components/profile/profile.ssr.test.tsx +++ b/packages/mosaic/src/components/profile/profile.ssr.test.tsx @@ -17,7 +17,7 @@ describe('Profile SSR', () => { Account Security - + Account page Security page diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index 16aa0baf843..a08aaeba2e0 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -15,6 +15,8 @@ import { Section } from '../section'; import type { ProfileRootProps } from './profile'; import { Profile } from './profile'; +const pageTitles: Record = { account: 'Account', security: 'Security' }; + function Surface(rootProps: Partial) { return ( ) { Security - + Account @@ -80,7 +82,7 @@ describe('Profile', () => { Account - + Account content , @@ -114,7 +116,7 @@ describe('Profile', () => { Security - + Account content Security content @@ -508,7 +510,7 @@ describe('Profile', () => { Account - + Account diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index ad113954be1..7678462555f 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -83,7 +83,7 @@ const Root = React.forwardRef(function Profile const pageTitleId = React.useId(); const pageTitleRef = React.useRef(null); const navTriggerRef = React.useRef(null); - const navItems = navItemsOf(children); + const [navItems, setNavItems] = React.useState(null); // Scoped to a layout so the replacement sheet never mounts open. const [navOpenIn, setNavOpenIn] = React.useState(null); const navOpen = navOpenIn === navLayout; @@ -111,6 +111,7 @@ const Root = React.forwardRef(function Profile value, selectPage, navItems, + setNavItems, pageTitleId, pageTitleRef, navTriggerRef, @@ -203,9 +204,9 @@ const Title = React.forwardRef(function P export type ProfileNavProps = MosaicComponentProps<'nav'>; -type NavItemMode = 'tab' | 'option' | 'label'; +type NavItemMode = 'tab' | 'option'; -// `Profile.Nav` children render again as the page title's label and as the select's options. +// `Profile.Nav` children render again as the select's options. const NavItemModeContext = React.createContext('tab'); function NavBranding() { @@ -217,16 +218,19 @@ function NavBranding() { } /** - * A direct child of `Profile.Root`, with `Profile.NavItem`s as its only children. Wide, they render - * as a tablist; compact, as the options of the page title's select, or a tablist in a sheet on a - * phone. + * Children are `Profile.NavItem`s only. Wide, they render as a tablist; compact, as the options of + * the page title's select, or a tablist in a sheet on a phone. */ const Nav = React.forwardRef(function ProfileNav( { children, render, xstyle, ...rest }, ref, ) { - const { titleId, renderBranding, compact, navLayout, navOpen, closeNav, navTriggerRef, inline } = + const { titleId, renderBranding, compact, navLayout, navOpen, closeNav, setNavItems, navTriggerRef, inline } = useProfileContext('Profile.Nav'); + useSafeLayoutEffect(() => { + setNavItems(children); + }, [children, setNavItems]); + useSafeLayoutEffect(() => () => setNavItems(null), [setNavItems]); const element = useRender({ defaultTagName: 'nav', render, @@ -276,24 +280,6 @@ const Nav = React.forwardRef(function ProfileNav( ); }); -function navItemsOf(children: React.ReactNode): React.ReactNode { - for (const child of React.Children.toArray(children)) { - if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { - continue; - } - if (child.type === Nav) { - return child.props.children; - } - if (child.type === React.Fragment) { - const items = navItemsOf(child.props.children); - if (items !== null) { - return items; - } - } - } - return null; -} - export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { value: string; icon?: React.ReactNode; @@ -307,11 +293,8 @@ const NavItem = React.forwardRef(functio { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, ref, ) { - const { compact, closeNav, value: selected } = useProfileContext('Profile.NavItem'); + const { compact, closeNav } = useProfileContext('Profile.NavItem'); const mode = React.useContext(NavItemModeContext); - if (mode === 'label') { - return value === selected ? children : null; - } const styleProps = mergeStyleProps( themeProps('profile-nav-item'), stylex.props(reset.base, styles.navItem, focusOutline.visible, mode === 'option' && styles.navItemOption, xstyle), @@ -376,7 +359,7 @@ const NavItem = React.forwardRef(functio ); }); -function PageTitle() { +function PageTitle({ title }: { title: React.ReactNode }) { const { navLayout, navOpen, @@ -390,11 +373,7 @@ function PageTitle() { navTriggerRef, } = useProfileContext('Profile.Content'); const level = useHeadingLevel(); - const label = ( - - {navItems} - - ); + const label = {title}; const triggerProps = mergeStyleProps( themeProps('profile-nav-trigger'), stylex.props(reset.base, styles.navTrigger, focusOutline.visible), @@ -463,11 +442,14 @@ function PageTitle() { ); } -export type ProfileContentProps = MosaicComponentProps<'div'>; +export interface ProfileContentProps extends MosaicComponentProps<'div'> { + /** The selected page's name, shown as the page title. */ + pageTitle: React.ReactNode; +} // A plain `div`, not `main`: the profile often renders inside the host's `main` or a dialog. const Content = React.forwardRef(function ProfileContent( - { children, render, xstyle, ...rest }, + { pageTitle, children, render, xstyle, ...rest }, ref, ) { const { inline, compact, renderBranding } = useProfileContext('Profile.Content'); @@ -494,7 +476,7 @@ const Content = React.forwardRef(function P >
- + {children} {inline && renderBranding && !compact ? ( @@ -551,7 +533,7 @@ const ContentPanel = React.forwardRef( * * }>Account * - * + * * … * * diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index 7caa9cde280..e7a4ecab3a6 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -58,7 +58,8 @@ export const OrganizationProfileView = React.forwardRef entry.id === activePage) ? activePage : entries[0].id; + const resolvedEntry = entries.find(entry => entry.id === activePage) ?? entries[0]; + const resolvedPage = resolvedEntry.id; return ( ))} - + {entries.map(entry => ( entry.id === activePage) ? activePage : entries[0].id; + const resolvedEntry = entries.find(entry => entry.id === activePage) ?? entries[0]; + const resolvedPage = resolvedEntry.id; return ( ))} - + {entries.map(entry => ( - + Members diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index a6222c0800c..bac6a1c6376 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -45,7 +45,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; Security - + … … @@ -60,7 +60,7 @@ scrolls, the columns a gap apart, held to a reading width and centered. A leading mark and trailing status. A `Badge` passed to `badge` is `neutral` unless it sets its own `color`. `Title` is a visually hidden heading: it names the navigation, the compact sheet, and — inside a dialog — the dialog itself, the way `Card.Title` does. -`Content` renders the page title above the pages: the selected `NavItem`'s label, as a heading that +`Content` renders its `pageTitle` above the pages, the selected page's name, as a heading that scrolls with the page and names it. A page's `Panel.Title` renders nothing there, and a ref on it reaches the page title instead. diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 101577557d3..27351db196e 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -181,7 +181,7 @@ function Surface({ ))} - + item.id === page)?.label}> {pages.map(item => (