Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .changeset/profile-ui-infra.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
144 changes: 144 additions & 0 deletions packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Animated> 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 (
<Animated>
<div>always here</div>
{showChild ? <div data-testid='new-child'>new child</div> : null}
</Animated>
);
}

const flush = () => new Promise(resolve => setTimeout(resolve, 50));

describe('Animated under React StrictMode', () => {
let animateSpy: ReturnType<typeof vi.spyOn>;

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(
<StrictMode>
<AnimatedList showChild={false} />
</StrictMode>,
{ wrapper },
);

await flush();
animateSpy.mockClear();

rerender(
<StrictMode>
<AnimatedList showChild />
</StrictMode>,
);

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<MutationObserver>();
const targets = new WeakMap<MutationObserver, Node>();
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(
<StrictMode>
<AnimatedList showChild={false} />
</StrictMode>,
{ 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;
}
});
});
1 change: 1 addition & 0 deletions packages/ui/src/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'profileSectionPrimaryButton',
'profileSectionButtonGroup',
'profilePage',
'profilePageContent',

'formattedPhoneNumber',
'formattedPhoneNumberFlag',
Expand Down
29 changes: 26 additions & 3 deletions packages/ui/src/elements/Animated.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof autoAnimate>;

function useSafeAutoAnimate(): [(node: HTMLElement | null) => void] {
const controllerRef = useRef<AutoAnimateController | null>(null);
const nodeRef = useRef<HTMLElement | null>(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 });
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/elements/AppearanceOverrides.tsx
Original file line number Diff line number Diff line change
@@ -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 <AppearanceContext.Provider value={{ value: augmented }}>{children}</AppearanceContext.Provider>;
};
31 changes: 10 additions & 21 deletions packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
}>;
Expand All @@ -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 (
<Col
elementDescriptor={descriptors.profilePageContent}
sx={[
theme => ({
...(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)`,
Expand Down
40 changes: 40 additions & 0 deletions packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Col
elementDescriptor={descriptors.page}
sx={outerSx ?? (t => ({ gap: t.space.$8, isolation: 'isolate' }))}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId(pageId)}
>
<Header.Root>
<Header.Title
localizationKey={titleKey}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
{alertContent !== undefined && <Card.Alert>{alertContent}</Card.Alert>}
{children}
</Col>
</Col>
);
};
Loading
Loading