diff --git a/.changeset/profile-ui-infra.md b/.changeset/profile-ui-infra.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-ui-infra.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx new file mode 100644 index 00000000000..b16319f537b --- /dev/null +++ b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx @@ -0,0 +1,144 @@ +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The global vitest setup mocks @formkit/auto-animate to a no-op stub. Restore +// the real module for this file (and for Animated.tsx's transitive import) so +// the production useSafeAutoAnimate wiring actually runs. +vi.mock('@formkit/auto-animate', async importOriginal => await importOriginal()); +vi.mock('@formkit/auto-animate/react', async importOriginal => await importOriginal()); + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { Animated } from '../../elements/Animated'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +/** + * Exercises the production wrapper (which uses useSafeAutoAnimate) + * under React StrictMode's mount → cleanup → remount cycle. StrictMode + * double-invokes effects and re-runs ref callbacks; without the + * destroy-previous-controller guard in useSafeAutoAnimate, a second + * MutationObserver would linger on the same node and its remain() animation + * would cancel the entrance (add) animation. We assert the user-facing outcome + * (add fires, no remain) and that exactly one MutationObserver stays active on + * the animated element after the cycle. + */ + +function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) { + continue; + } + if ( + keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ) + ) { + adds.push(call); + } + if (keyframes.some((kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'))) { + remains.push(call); + } + } + return { adds, remains }; +} + +function AnimatedList({ showChild }: { showChild: boolean }) { + return ( + +
always here
+ {showChild ?
new child
: null} +
+ ); +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 50)); + +describe('Animated under React StrictMode', () => { + let animateSpy: ReturnType; + + beforeEach(() => { + animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + }); + + afterEach(() => { + animateSpy.mockRestore(); + }); + + it('fires the add animation (and no remain) when a child is added', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { rerender } = render( + + + , + { wrapper }, + ); + + await flush(); + animateSpy.mockClear(); + + rerender( + + + , + ); + + await flush(); + + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + // remain() would only fire if a lingering second observer from the + // StrictMode remount re-processed the mutation — the guard prevents it. + expect(remains.length).toBe(0); + }); + + it('keeps exactly one active MutationObserver on the element after the StrictMode cycle', async () => { + const activeChildListObservers = new Set(); + const targets = new WeakMap(); + const origObserve = MutationObserver.prototype.observe; + const origDisconnect = MutationObserver.prototype.disconnect; + + MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) { + if (options?.childList) { + activeChildListObservers.add(this); + targets.set(this, target); + } + return origObserve.call(this, target, options); + }; + MutationObserver.prototype.disconnect = function () { + activeChildListObservers.delete(this); + return origDisconnect.call(this); + }; + + try { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + await flush(); + + const el = screen.getByText('always here').parentElement; + const activeOnEl = [...activeChildListObservers].filter(mo => targets.get(mo) === el).length; + expect(activeOnEl).toBe(1); + } finally { + MutationObserver.prototype.observe = origObserve; + MutationObserver.prototype.disconnect = origDisconnect; + } + }); +}); diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 69eca6184fe..a8bcde9bbd5 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -463,6 +463,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'profileSectionPrimaryButton', 'profileSectionButtonGroup', 'profilePage', + 'profilePageContent', 'formattedPhoneNumber', 'formattedPhoneNumberFlag', diff --git a/packages/ui/src/elements/Animated.tsx b/packages/ui/src/elements/Animated.tsx index 93f1ab4129a..8a45acccc6e 100644 --- a/packages/ui/src/elements/Animated.tsx +++ b/packages/ui/src/elements/Animated.tsx @@ -1,14 +1,37 @@ -import { useAutoAnimate } from '@formkit/auto-animate/react'; -import { cloneElement, type PropsWithChildren } from 'react'; +import autoAnimate from '@formkit/auto-animate'; +import { cloneElement, type PropsWithChildren, useCallback, useRef } from 'react'; import { useAppearance } from '@/customizables'; type AnimatedProps = PropsWithChildren<{ asChild?: boolean }>; +type AutoAnimateController = ReturnType; + +function useSafeAutoAnimate(): [(node: HTMLElement | null) => void] { + const controllerRef = useRef(null); + const nodeRef = useRef(null); + + const ref = useCallback((node: HTMLElement | null) => { + if (node && node === nodeRef.current && controllerRef.current) { + return; + } + if (controllerRef.current) { + controllerRef.current.destroy?.(); + controllerRef.current = null; + } + nodeRef.current = node; + if (node instanceof HTMLElement && typeof node.animate === 'function') { + controllerRef.current = autoAnimate(node); + } + }, []); + + return [ref]; +} + export const Animated = (props: AnimatedProps) => { const { children, asChild } = props; const { animations } = useAppearance().parsedOptions; - const [parent] = useAutoAnimate(); + const [parent] = useSafeAutoAnimate(); if (asChild) { return cloneElement(children as any, { ref: animations ? parent : null }); diff --git a/packages/ui/src/elements/AppearanceOverrides.tsx b/packages/ui/src/elements/AppearanceOverrides.tsx new file mode 100644 index 00000000000..7941af09906 --- /dev/null +++ b/packages/ui/src/elements/AppearanceOverrides.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { AppearanceContext, useAppearance } from '../customizables'; +import type { Elements } from '../internal/appearance'; + +export const AppearanceOverrides = ({ elements, children }: { elements: Elements; children: React.ReactNode }) => { + const appearance = useAppearance(); + + const augmented = React.useMemo(() => { + // position 0 is the base theme; overrides slot in immediately above it + const [base, ...rest] = appearance.parsedElements; + return { ...appearance, parsedElements: [base, elements, ...rest] }; + }, [appearance, elements]); + + return {children}; +}; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index 0e821859137..a83388a4af3 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,15 +1,10 @@ import type { PropsWithChildren } from 'react'; -import { Col } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; import type { ThemableCssProp } from '../../styledSystem'; import { mqu } from '../../styledSystem'; type ProfileCardPageProps = PropsWithChildren<{ - /** - * Whether to apply the standard per-page padding. - * @default true - */ - padding?: boolean; /** * Whether the page should bleed past the standard padding by applying matching * negative inline margins, so children render flush with the scroll-gutter / card border. @@ -18,7 +13,6 @@ type ProfileCardPageProps = PropsWithChildren<{ bleeding?: boolean; /** * Extra styles for the page wrapper — e.g. `flex: 1` to fill the scroll box height. - * Ignored when neither `padding` nor `bleeding` apply (no wrapper renders). */ sx?: ThemableCssProp; }>; @@ -29,24 +23,19 @@ type ProfileCardPageProps = PropsWithChildren<{ * Each routed page inside `UserProfile` / `OrganizationProfile` should wrap its content * in this component */ -export const ProfileCardPage = ({ children, padding = true, bleeding = false, sx }: ProfileCardPageProps) => { - if (!padding && !bleeding) { - return <>{children}; - } - +export const ProfileCardPage = ({ children, bleeding = false, sx }: ProfileCardPageProps) => { return ( ({ - ...(padding && { - paddingTop: theme.space.$7, - paddingBottom: theme.space.$7, - paddingInlineStart: theme.space.$8, - paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter on the parent - [mqu.sm]: { - padding: `${theme.space.$8} ${theme.space.$5}`, - }, - }), + paddingTop: theme.space.$7, + paddingBottom: theme.space.$7, + paddingInlineStart: theme.space.$8, + paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter on the parent + [mqu.sm]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, ...(bleeding && { marginInlineStart: `calc(${theme.space.$8} * -1)`, marginInlineEnd: `calc(${theme.space.$6} * -1)`, diff --git a/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx b/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx new file mode 100644 index 00000000000..71a2e770605 --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx @@ -0,0 +1,40 @@ +import type { ProfilePageId } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; + +import type { LocalizationKey } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; +import type { ThemableCssProp } from '../../styledSystem'; + +type ProfilePagePanelProps = PropsWithChildren<{ + pageId: ProfilePageId; + titleKey: LocalizationKey; + alertContent?: ReactNode; + outerSx?: ThemableCssProp; +}>; + +export const ProfilePagePanel = ({ children, pageId, titleKey, alertContent, outerSx }: ProfilePagePanelProps) => { + return ( + ({ gap: t.space.$8, isolation: 'isolate' }))} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {alertContent !== undefined && {alertContent}} + {children} + + + ); +}; diff --git a/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx new file mode 100644 index 00000000000..c3ff1ce4845 --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../../hooks'; +import { ProfileCard } from '../index'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('ProfilePagePanel', () => { + beforeEach(() => { + clearFetchCache(); + }); + + it('renders title heading', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
section content
+
, + { wrapper }, + ); + + expect(screen.getByRole('heading')).toBeInTheDocument(); + screen.getByText('section content'); + }); + + it('renders alert content when provided', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
content
+
, + { wrapper }, + ); + + screen.getByText('Something went wrong'); + }); + + it('does not render Card.Alert when alertContent is undefined', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render( + +
content
+
, + { wrapper }, + ); + + const alert = container.querySelector('[class*="alert"]'); + expect(alert).not.toBeInTheDocument(); + }); + + it('renders children', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
First
+
Second
+
, + { wrapper }, + ); + + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/elements/ProfileCard/index.ts b/packages/ui/src/elements/ProfileCard/index.ts index 84df2ddd56e..54f26eae9fd 100644 --- a/packages/ui/src/elements/ProfileCard/index.ts +++ b/packages/ui/src/elements/ProfileCard/index.ts @@ -1,9 +1,11 @@ import { ProfileCardContent } from './ProfileCardContent'; import { ProfileCardPage } from './ProfileCardPage'; import { ProfileCardRoot } from './ProfileCardRoot'; +import { ProfilePagePanel } from './ProfilePagePanel'; export const ProfileCard = { Root: ProfileCardRoot, Content: ProfileCardContent, Page: ProfileCardPage, + PagePanel: ProfilePagePanel, }; diff --git a/packages/ui/src/hooks/useSafeState.ts b/packages/ui/src/hooks/useSafeState.ts index cba72ee5eec..7d3641738e9 100644 --- a/packages/ui/src/hooks/useSafeState.ts +++ b/packages/ui/src/hooks/useSafeState.ts @@ -13,6 +13,7 @@ export function useSafeState(initialState?: S | (() => S)) { const isMountedRef = React.useRef(true); React.useEffect(() => { + isMountedRef.current = true; return () => { isMountedRef.current = false; }; diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 71d20488744..b9706b9c8e1 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -597,6 +597,7 @@ export type ElementsConfig = { profileSectionPrimaryButton: WithOptions; profileSectionButtonGroup: WithOptions; profilePage: WithOptions; + profilePageContent: WithOptions; // TODO: review formattedPhoneNumber: WithOptions; diff --git a/packages/ui/src/internal/styleCacheStore.ts b/packages/ui/src/internal/styleCacheStore.ts new file mode 100644 index 00000000000..6ea183a5ea1 --- /dev/null +++ b/packages/ui/src/internal/styleCacheStore.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line no-restricted-imports +import type { EmotionCache } from '@emotion/cache'; + +const store = new WeakMap(); + +export function getStyleCache(clerkInstance: object): EmotionCache | undefined { + return store.get(clerkInstance); +} + +export function setStyleCache(clerkInstance: object, cache: EmotionCache): void { + store.set(clerkInstance, cache); +} diff --git a/packages/ui/src/primitives/Spinner.tsx b/packages/ui/src/primitives/Spinner.tsx index a528596e348..5f0f5eed7b4 100644 --- a/packages/ui/src/primitives/Spinner.tsx +++ b/packages/ui/src/primitives/Spinner.tsx @@ -6,6 +6,7 @@ const { size, thickness, speed } = createCssVariables('speed', 'size', 'thicknes const { applyVariants, filterProps } = createVariants(theme => { return { base: { + boxSizing: 'border-box', display: 'inline-block', borderRadius: '99999px', borderTop: `${thickness} solid currentColor`, diff --git a/packages/ui/src/styledSystem/StyleCacheProvider.tsx b/packages/ui/src/styledSystem/StyleCacheProvider.tsx index 0863f32172b..d3cfbdec000 100644 --- a/packages/ui/src/styledSystem/StyleCacheProvider.tsx +++ b/packages/ui/src/styledSystem/StyleCacheProvider.tsx @@ -1,10 +1,8 @@ // eslint-disable-next-line no-restricted-imports -import createCache from '@emotion/cache'; -// eslint-disable-next-line no-restricted-imports -import { CacheProvider, type SerializedStyles } from '@emotion/react'; +import { CacheProvider } from '@emotion/react'; import React, { useMemo } from 'react'; -const el = document.querySelector('style#cl-style-insertion-point'); +import { createEmotionCache } from './createEmotionCache'; type StyleCacheProviderProps = React.PropsWithChildren<{ /** The nonce value for CSP (Content Security Policy). */ @@ -14,27 +12,10 @@ type StyleCacheProviderProps = React.PropsWithChildren<{ }>; export const StyleCacheProvider = (props: StyleCacheProviderProps) => { - const cache = useMemo(() => { - const emotionCache = createCache({ - key: 'cl-internal', - prepend: props.cssLayerName ? false : !el, - insertionPoint: el ? (el as HTMLElement) : undefined, - nonce: props.nonce, - }); - - if (props.cssLayerName) { - const prevInsert = emotionCache.insert.bind(emotionCache); - emotionCache.insert = (selector: string, serialized: SerializedStyles, sheet: any, shouldCache: boolean) => { - if (serialized && typeof serialized.styles === 'string' && !serialized.styles.startsWith('@layer')) { - const newSerialized = { ...serialized }; - newSerialized.styles = `@layer ${props.cssLayerName} {${serialized.styles}}`; - return prevInsert(selector, newSerialized, sheet, shouldCache); - } - return prevInsert(selector, serialized, sheet, shouldCache); - }; - } - return emotionCache; - }, [props.nonce, props.cssLayerName]); + const cache = useMemo( + () => createEmotionCache({ nonce: props.nonce, cssLayerName: props.cssLayerName }), + [props.nonce, props.cssLayerName], + ); return {props.children}; }; diff --git a/packages/ui/src/styledSystem/createEmotionCache.ts b/packages/ui/src/styledSystem/createEmotionCache.ts new file mode 100644 index 00000000000..b6660c0cbe2 --- /dev/null +++ b/packages/ui/src/styledSystem/createEmotionCache.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line no-restricted-imports +import createCache, { type EmotionCache } from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import type { SerializedStyles } from '@emotion/react'; + +type CreateEmotionCacheOptions = { + /** The nonce value for CSP (Content Security Policy). */ + nonce?: string; + /** The CSS layer name to wrap style insertions in. */ + cssLayerName?: string; +}; + +/** + * Creates the shared `cl-internal` emotion cache used by both the AIO + * `StyleCacheProvider` and the composed `ProfileProviderShell`. When + * `cssLayerName` is set, every insertion is wrapped in `@layer { ... }` + * so consumers can control cascade precedence relative to their own styles. + */ +export function createEmotionCache({ nonce, cssLayerName }: CreateEmotionCacheOptions): EmotionCache { + const el = typeof document !== 'undefined' ? document.querySelector('style#cl-style-insertion-point') : null; + const cache = createCache({ + key: 'cl-internal', + prepend: cssLayerName ? false : !el, + insertionPoint: el ? (el as HTMLElement) : undefined, + nonce, + }); + + if (cssLayerName) { + const prevInsert = cache.insert.bind(cache); + + cache.insert = (selector: string, serialized: SerializedStyles, sheet: any, shouldCache: boolean) => { + if (serialized && typeof serialized.styles === 'string' && !serialized.styles.startsWith('@layer')) { + const wrapped = { ...serialized, styles: `@layer ${cssLayerName} {${serialized.styles}}` }; + return prevInsert(selector, wrapped, sheet, shouldCache); + } + return prevInsert(selector, serialized, sheet, shouldCache); + }; + } + + return cache; +}