From b5eb15bf81d94456309d6ca44ad423a4175d50b6 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 20 Dec 2024 13:45:39 -0600 Subject: [PATCH] feat(clerk-react): Support for fallback prop (#4723) Co-authored-by: Alex Carpenter --- .changeset/serious-stingrays-learn.md | 10 + .../src/app/create-organization/page.tsx | 9 + .../src/app/organization-list/page.tsx | 9 + .../src/app/organization-profile/page.tsx | 12 + .../next-app-router/src/app/page.tsx | 5 +- .../src/app/sign-in/[[...catchall]]/page.tsx | 1 + .../src/app/sign-up/[[...catchall]]/page.tsx | 1 + .../next-app-router/src/app/switcher/page.tsx | 7 +- .../src/app/user-button/page.tsx | 9 + .../src/app/user/[[...catchall]]/page.tsx | 2 +- .../next-app-router/src/app/waitlist/page.tsx | 9 + integration/templates/react-vite/src/App.tsx | 2 +- .../src/create-organization/index.tsx | 9 + .../src/custom-user-button/index.tsx | 2 +- .../src/custom-user-profile/index.tsx | 5 +- integration/templates/react-vite/src/main.tsx | 25 + .../src/organization-list/index.tsx | 9 + .../src/organization-profile/index.tsx | 12 + .../react-vite/src/sign-in/index.tsx | 1 + .../react-vite/src/sign-up/index.tsx | 1 + .../react-vite/src/user-button/index.tsx | 9 + .../templates/react-vite/src/user/index.tsx | 5 +- .../react-vite/src/waitlist/index.tsx | 9 + integration/testUtils/appPageObject.ts | 4 +- integration/tests/components.test.ts | 109 ++++ .../src/client-boundary/uiComponents.tsx | 10 +- .../src/components/ClerkHostRenderer.tsx | 118 +++++ .../react/src/components/uiComponents.tsx | 494 ++++++++++-------- packages/react/src/components/withClerk.tsx | 32 +- packages/react/src/errors/messages.ts | 2 - packages/react/src/types.ts | 2 +- .../src/utils/useWaitForComponentMount.ts | 80 +++ 32 files changed, 768 insertions(+), 246 deletions(-) create mode 100644 .changeset/serious-stingrays-learn.md create mode 100644 integration/templates/next-app-router/src/app/create-organization/page.tsx create mode 100644 integration/templates/next-app-router/src/app/organization-list/page.tsx create mode 100644 integration/templates/next-app-router/src/app/organization-profile/page.tsx create mode 100644 integration/templates/next-app-router/src/app/user-button/page.tsx create mode 100644 integration/templates/next-app-router/src/app/waitlist/page.tsx create mode 100644 integration/templates/react-vite/src/create-organization/index.tsx create mode 100644 integration/templates/react-vite/src/organization-list/index.tsx create mode 100644 integration/templates/react-vite/src/organization-profile/index.tsx create mode 100644 integration/templates/react-vite/src/user-button/index.tsx create mode 100644 integration/templates/react-vite/src/waitlist/index.tsx create mode 100644 integration/tests/components.test.ts create mode 100644 packages/react/src/components/ClerkHostRenderer.tsx create mode 100644 packages/react/src/utils/useWaitForComponentMount.ts diff --git a/.changeset/serious-stingrays-learn.md b/.changeset/serious-stingrays-learn.md new file mode 100644 index 0000000000..0baff9b9b3 --- /dev/null +++ b/.changeset/serious-stingrays-learn.md @@ -0,0 +1,10 @@ +--- +'@clerk/clerk-react': minor +--- + +Adds support for a `fallback` prop on Clerk's components. This allows rendering of a placeholder element while Clerk's components are mounting. Use this to help mitigate layout shift when using Clerk's components. Example usage: + + +```tsx +} /> +``` diff --git a/integration/templates/next-app-router/src/app/create-organization/page.tsx b/integration/templates/next-app-router/src/app/create-organization/page.tsx new file mode 100644 index 0000000000..ee02fb133d --- /dev/null +++ b/integration/templates/next-app-router/src/app/create-organization/page.tsx @@ -0,0 +1,9 @@ +import { CreateOrganization } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading create organization} /> +
+ ); +} diff --git a/integration/templates/next-app-router/src/app/organization-list/page.tsx b/integration/templates/next-app-router/src/app/organization-list/page.tsx new file mode 100644 index 0000000000..ac4e1e6a5f --- /dev/null +++ b/integration/templates/next-app-router/src/app/organization-list/page.tsx @@ -0,0 +1,9 @@ +import { OrganizationList } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading organization list} /> +
+ ); +} diff --git a/integration/templates/next-app-router/src/app/organization-profile/page.tsx b/integration/templates/next-app-router/src/app/organization-profile/page.tsx new file mode 100644 index 0000000000..2145cf662b --- /dev/null +++ b/integration/templates/next-app-router/src/app/organization-profile/page.tsx @@ -0,0 +1,12 @@ +import { OrganizationProfile } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading organization profile} + /> +
+ ); +} diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx index 72108e9580..a4fd6f599c 100644 --- a/integration/templates/next-app-router/src/app/page.tsx +++ b/integration/templates/next-app-router/src/app/page.tsx @@ -1,11 +1,12 @@ -import { SignedIn, SignedOut, SignIn, UserButton, Protect } from '@clerk/nextjs'; +import { SignedIn, SignedOut, SignIn, UserButton, Protect, OrganizationSwitcher } from '@clerk/nextjs'; import Link from 'next/link'; import { ClientId } from './client-id'; export default function Home() { return (
- + Loading user button} /> + Loading organization switcher} /> SignedIn SignedOut diff --git a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx index a0cf2adf13..d574c6244f 100644 --- a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx +++ b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx @@ -7,6 +7,7 @@ export default function Page() { routing={'path'} path={'/sign-in'} signUpUrl={'/sign-up'} + fallback={<>Loading sign in} __experimental={{ combinedProps: {}, }} diff --git a/integration/templates/next-app-router/src/app/sign-up/[[...catchall]]/page.tsx b/integration/templates/next-app-router/src/app/sign-up/[[...catchall]]/page.tsx index 687bcf1ddd..b26b0967f3 100644 --- a/integration/templates/next-app-router/src/app/sign-up/[[...catchall]]/page.tsx +++ b/integration/templates/next-app-router/src/app/sign-up/[[...catchall]]/page.tsx @@ -7,6 +7,7 @@ export default function Page() { routing={'path'} path={'/sign-up'} signInUrl={'/sign-in'} + fallback={<>Loading sign up} /> ); diff --git a/integration/templates/next-app-router/src/app/switcher/page.tsx b/integration/templates/next-app-router/src/app/switcher/page.tsx index 849cecc61c..5bb8843f04 100644 --- a/integration/templates/next-app-router/src/app/switcher/page.tsx +++ b/integration/templates/next-app-router/src/app/switcher/page.tsx @@ -1,5 +1,10 @@ import { OrganizationSwitcher } from '@clerk/nextjs'; export default function Page() { - return ; + return ( + Loading organization switcher} + /> + ); } diff --git a/integration/templates/next-app-router/src/app/user-button/page.tsx b/integration/templates/next-app-router/src/app/user-button/page.tsx new file mode 100644 index 0000000000..9d776a7809 --- /dev/null +++ b/integration/templates/next-app-router/src/app/user-button/page.tsx @@ -0,0 +1,9 @@ +import { UserButton } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading user button} /> +
+ ); +} diff --git a/integration/templates/next-app-router/src/app/user/[[...catchall]]/page.tsx b/integration/templates/next-app-router/src/app/user/[[...catchall]]/page.tsx index 04f5b08a89..8fbe316558 100644 --- a/integration/templates/next-app-router/src/app/user/[[...catchall]]/page.tsx +++ b/integration/templates/next-app-router/src/app/user/[[...catchall]]/page.tsx @@ -3,7 +3,7 @@ import { UserProfile } from '@clerk/nextjs'; export default function Page() { return (
- + Loading user profile} />
); } diff --git a/integration/templates/next-app-router/src/app/waitlist/page.tsx b/integration/templates/next-app-router/src/app/waitlist/page.tsx new file mode 100644 index 0000000000..5638940a29 --- /dev/null +++ b/integration/templates/next-app-router/src/app/waitlist/page.tsx @@ -0,0 +1,9 @@ +import { Waitlist } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading waitlist} /> +
+ ); +} diff --git a/integration/templates/react-vite/src/App.tsx b/integration/templates/react-vite/src/App.tsx index 98e3530af6..3696ded74d 100644 --- a/integration/templates/react-vite/src/App.tsx +++ b/integration/templates/react-vite/src/App.tsx @@ -6,7 +6,7 @@ function App() { return (
- + Loading organization switcher} /> SignedOut SignedIn diff --git a/integration/templates/react-vite/src/create-organization/index.tsx b/integration/templates/react-vite/src/create-organization/index.tsx new file mode 100644 index 0000000000..7f268110e7 --- /dev/null +++ b/integration/templates/react-vite/src/create-organization/index.tsx @@ -0,0 +1,9 @@ +import { CreateOrganization } from '@clerk/clerk-react'; + +export default function Page() { + return ( +
+ Loading create organization} /> +
+ ); +} diff --git a/integration/templates/react-vite/src/custom-user-button/index.tsx b/integration/templates/react-vite/src/custom-user-button/index.tsx index e283cddd76..728bb51f43 100644 --- a/integration/templates/react-vite/src/custom-user-button/index.tsx +++ b/integration/templates/react-vite/src/custom-user-button/index.tsx @@ -22,7 +22,7 @@ function Page1() { export default function Page() { return ( - + Loading user button}> 🙃

} diff --git a/integration/templates/react-vite/src/custom-user-profile/index.tsx b/integration/templates/react-vite/src/custom-user-profile/index.tsx index 8a259a1639..c6f2fa42e8 100644 --- a/integration/templates/react-vite/src/custom-user-profile/index.tsx +++ b/integration/templates/react-vite/src/custom-user-profile/index.tsx @@ -22,7 +22,10 @@ function Page1() { export default function Page() { return ( - + Loading user profile} + path={'/custom-user-profile'} + > 🙃

} diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index 86f1252b5e..f011cf9726 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -11,6 +11,11 @@ import UserProfile from './user'; import UserProfileCustom from './custom-user-profile'; import UserButtonCustom from './custom-user-button'; import UserButtonCustomTrigger from './custom-user-button-trigger'; +import UserButton from './user-button'; +import Waitlist from './waitlist'; +import OrganizationProfile from './organization-profile'; +import OrganizationList from './organization-list'; +import CreateOrganization from './create-organization'; const Root = () => { const navigate = useNavigate(); @@ -53,6 +58,10 @@ const router = createBrowserRouter([ path: '/user/*', element: , }, + { + path: '/user-button', + element: , + }, { path: '/protected', element: , @@ -69,6 +78,22 @@ const router = createBrowserRouter([ path: '/custom-user-button-trigger', element: , }, + { + path: '/waitlist', + element: , + }, + { + path: '/organization-profile', + element: , + }, + { + path: '/organization-list', + element: , + }, + { + path: '/create-organization', + element: , + }, ], }, ]); diff --git a/integration/templates/react-vite/src/organization-list/index.tsx b/integration/templates/react-vite/src/organization-list/index.tsx new file mode 100644 index 0000000000..393856f058 --- /dev/null +++ b/integration/templates/react-vite/src/organization-list/index.tsx @@ -0,0 +1,9 @@ +import { OrganizationList } from '@clerk/clerk-react'; + +export default function Page() { + return ( +
+ Loading organization list} /> +
+ ); +} diff --git a/integration/templates/react-vite/src/organization-profile/index.tsx b/integration/templates/react-vite/src/organization-profile/index.tsx new file mode 100644 index 0000000000..144b8b1a53 --- /dev/null +++ b/integration/templates/react-vite/src/organization-profile/index.tsx @@ -0,0 +1,12 @@ +import { OrganizationProfile } from '@clerk/clerk-react'; + +export default function Page() { + return ( +
+ Loading organization profile} + /> +
+ ); +} diff --git a/integration/templates/react-vite/src/sign-in/index.tsx b/integration/templates/react-vite/src/sign-in/index.tsx index 39caef7d00..7ec2593036 100644 --- a/integration/templates/react-vite/src/sign-in/index.tsx +++ b/integration/templates/react-vite/src/sign-in/index.tsx @@ -6,6 +6,7 @@ export default function Page() { Loading sign in} /> ); diff --git a/integration/templates/react-vite/src/sign-up/index.tsx b/integration/templates/react-vite/src/sign-up/index.tsx index 9aef78cf85..fa00b90a68 100644 --- a/integration/templates/react-vite/src/sign-up/index.tsx +++ b/integration/templates/react-vite/src/sign-up/index.tsx @@ -6,6 +6,7 @@ export default function Page() { Loading sign up} /> ); diff --git a/integration/templates/react-vite/src/user-button/index.tsx b/integration/templates/react-vite/src/user-button/index.tsx new file mode 100644 index 0000000000..a8c6df3a10 --- /dev/null +++ b/integration/templates/react-vite/src/user-button/index.tsx @@ -0,0 +1,9 @@ +import { UserButton } from '@clerk/clerk-react'; + +export default function Page() { + return ( +
+ Loading user button} /> +
+ ); +} diff --git a/integration/templates/react-vite/src/user/index.tsx b/integration/templates/react-vite/src/user/index.tsx index 007d4639ec..ca6b2c770f 100644 --- a/integration/templates/react-vite/src/user/index.tsx +++ b/integration/templates/react-vite/src/user/index.tsx @@ -3,7 +3,10 @@ import { UserProfile } from '@clerk/clerk-react'; export default function Page() { return (
- + Loading user profile} + />
); } diff --git a/integration/templates/react-vite/src/waitlist/index.tsx b/integration/templates/react-vite/src/waitlist/index.tsx new file mode 100644 index 0000000000..effbf8a5a4 --- /dev/null +++ b/integration/templates/react-vite/src/waitlist/index.tsx @@ -0,0 +1,9 @@ +import { Waitlist } from '@clerk/clerk-react'; + +export default function Page() { + return ( +
+ Loading waitlist} /> +
+ ); +} diff --git a/integration/testUtils/appPageObject.ts b/integration/testUtils/appPageObject.ts index 70b2b21832..306c293a39 100644 --- a/integration/testUtils/appPageObject.ts +++ b/integration/testUtils/appPageObject.ts @@ -13,7 +13,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) // do not fail the test if interstitial is returned (401) } }, - goToRelative: (path: string, opts: { searchParams?: URLSearchParams; timeout?: number } = {}) => { + goToRelative: (path: string, opts: { waitUntil?: any; searchParams?: URLSearchParams; timeout?: number } = {}) => { let url: URL; try { @@ -35,7 +35,7 @@ export const createAppPageObject = (testArgs: { page: Page }, app: Application) if (opts.searchParams) { url.search = opts.searchParams.toString(); } - return page.goto(url.toString(), { timeout: opts.timeout ?? 20000 }); + return page.goto(url.toString(), { timeout: opts.timeout ?? 20000, waitUntil: opts.waitUntil }); }, waitForClerkJsLoaded: async () => { return page.waitForFunction(() => { diff --git a/integration/tests/components.test.ts b/integration/tests/components.test.ts new file mode 100644 index 0000000000..3863d8c766 --- /dev/null +++ b/integration/tests/components.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeOrganization, FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component smoke tests @generic', ({ app }) => { + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + const user = await u.services.users.createBapiUser(fakeUser); + fakeOrganization = await u.services.users.createFakeOrganization(user.id); + }); + + test.afterAll(async () => { + await app.teardown(); + await fakeUser.deleteIfExists(); + await fakeOrganization.delete(); + }); + + const components = [ + { + name: 'SignIn', + path: '/sign-in', + fallback: 'Loading sign in', + }, + { + name: 'SignUp', + path: '/sign-up', + fallback: 'Loading sign up', + }, + { + name: 'UserProfile', + path: '/user', + protected: true, + fallback: 'Loading user profile', + }, + { + name: 'UserButton', + path: '/user-button', + protected: true, + fallback: 'Loading user button', + }, + { + name: 'Waitlist', + path: '/waitlist', + fallback: 'Loading waitlist', + }, + { + name: 'OrganizationSwitcher', + path: '/', + fallback: 'Loading organization switcher', + protected: true, + }, + { + name: 'OrganizationProfile', + path: '/organization-profile', + fallback: 'Loading organization profile', + protected: true, + }, + { + name: 'OrganizationList', + path: '/organization-list', + fallback: 'Loading organization list', + protected: true, + }, + { + name: 'CreateOrganization', + path: '/create-organization', + fallback: 'Loading create organization', + protected: true, + }, + ]; + + const signIn = async ({ app, page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + }; + + const signOut = async ({ app, page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.evaluate(async () => { + await window.Clerk.signOut(); + }); + }; + + for (const component of components) { + test(`${component.name} supports fallback`, async ({ page, context }) => { + // eslint-disable-next-line playwright/no-conditional-in-test + if (component.protected) { + await signIn({ app, page, context }); + } + + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative(component.path, { waitUntil: 'commit' }); + await expect(u.page.getByText(component.fallback)).toBeVisible(); + + await signOut({ app, page, context }); + }); + } +}); diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index a4dde9da72..054e54a428 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -6,7 +6,7 @@ import { SignUp as BaseSignUp, UserProfile as BaseUserProfile, } from '@clerk/clerk-react'; -import type { OrganizationProfileProps, SignInProps, SignUpProps, UserProfileProps } from '@clerk/types'; +import type { ComponentProps } from 'react'; import React from 'react'; import { useEnforceCorrectRoutingProps } from './hooks/useEnforceRoutingProps'; @@ -29,7 +29,7 @@ export { // Also the `typeof BaseUserProfile` is used to resolve the following error: // "The inferred type of 'UserProfile' cannot be named without a reference to ..." export const UserProfile: typeof BaseUserProfile = Object.assign( - (props: UserProfileProps) => { + (props: ComponentProps) => { return ; }, { ...BaseUserProfile }, @@ -40,16 +40,16 @@ export const UserProfile: typeof BaseUserProfile = Object.assign( // Also the `typeof BaseOrganizationProfile` is used to resolved the following error: // "The inferred type of 'OrganizationProfile' cannot be named without a reference to ..." export const OrganizationProfile: typeof BaseOrganizationProfile = Object.assign( - (props: OrganizationProfileProps) => { + (props: ComponentProps) => { return ; }, { ...BaseOrganizationProfile }, ); -export const SignIn = (props: SignInProps) => { +export const SignIn = (props: ComponentProps) => { return ; }; -export const SignUp = (props: SignUpProps) => { +export const SignUp = (props: ComponentProps) => { return ; }; diff --git a/packages/react/src/components/ClerkHostRenderer.tsx b/packages/react/src/components/ClerkHostRenderer.tsx new file mode 100644 index 0000000000..19b8187718 --- /dev/null +++ b/packages/react/src/components/ClerkHostRenderer.tsx @@ -0,0 +1,118 @@ +import { without } from '@clerk/shared/object'; +import { isDeeplyEqual } from '@clerk/shared/react'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import type { MountProps, OpenProps } from '../types'; + +const isMountProps = (props: any): props is MountProps => { + return 'mount' in props; +}; + +const isOpenProps = (props: any): props is OpenProps => { + return 'open' in props; +}; +// README: should be a class pure component in order for mount and unmount +// lifecycle props to be invoked correctly. Replacing the class component with a +// functional component wrapped with a React.memo is not identical to the original +// class implementation due to React intricacies such as the useEffect’s cleanup +// seems to run AFTER unmount, while componentWillUnmount runs BEFORE. + +// More information can be found at https://clerk.slack.com/archives/C015S0BGH8R/p1624891993016300 + +// The function Portal implementation is commented out for future reference. + +// const Portal = React.memo(({ props, mount, unmount }: MountProps) => { +// const portalRef = React.createRef(); + +// useEffect(() => { +// if (portalRef.current) { +// mount(portalRef.current, props); +// } +// return () => { +// if (portalRef.current) { +// unmount(portalRef.current); +// } +// }; +// }, []); + +// return
; +// }); + +// Portal.displayName = 'ClerkPortal'; + +/** + * Used to orchestrate mounting of Clerk components in a host React application. + * Components are rendered into a specific DOM node using mount/unmount methods provided by the Clerk class. + */ +export class ClerkHostRenderer extends React.PureComponent< + PropsWithChildren< + (MountProps | OpenProps) & { + component?: string; + hideRootHtmlElement?: boolean; + rootProps?: JSX.IntrinsicElements['div']; + } + > +> { + private rootRef = React.createRef(); + + componentDidUpdate(_prevProps: Readonly) { + if (!isMountProps(_prevProps) || !isMountProps(this.props)) { + return; + } + + // Remove children and customPages from props before comparing + // children might hold circular references which deepEqual can't handle + // and the implementation of customPages or customMenuItems relies on props getting new references + const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children'); + const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children'); + // instead, we simply use the length of customPages to determine if it changed or not + const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length; + const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length; + + if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { + if (this.rootRef.current) { + this.props.updateProps({ node: this.rootRef.current, props: this.props.props }); + } + } + } + + componentDidMount() { + if (this.rootRef.current) { + if (isMountProps(this.props)) { + this.props.mount(this.rootRef.current, this.props.props); + } + + if (isOpenProps(this.props)) { + this.props.open(this.props.props); + } + } + } + + componentWillUnmount() { + if (this.rootRef.current) { + if (isMountProps(this.props)) { + this.props.unmount(this.rootRef.current); + } + if (isOpenProps(this.props)) { + this.props.close(); + } + } + } + + render() { + const { hideRootHtmlElement = false } = this.props; + const rootAttributes = { + ref: this.rootRef, + ...this.props.rootProps, + ...(this.props.component && { 'data-clerk-component': this.props.component }), + }; + + return ( + <> + {!hideRootHtmlElement &&
} + {this.props.children} + + ); + } +} diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index a0d2a3263d..63b06dc7b2 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,5 +1,3 @@ -import { without } from '@clerk/shared/object'; -import { isDeeplyEqual } from '@clerk/shared/react'; import { logErrorInDevMode } from '@clerk/shared/utils'; import type { CreateOrganizationProps, @@ -14,7 +12,7 @@ import type { WaitlistProps, Without, } from '@clerk/types'; -import type { PropsWithChildren } from 'react'; +import type { PropsWithChildren, ReactNode } from 'react'; import React, { createContext, createElement, useContext } from 'react'; import { @@ -29,7 +27,6 @@ import { import type { CustomPortalsRendererProps, MountProps, - OpenProps, OrganizationProfileLinkProps, OrganizationProfilePageProps, UserButtonActionProps, @@ -44,8 +41,17 @@ import { useUserButtonCustomMenuItems, useUserProfileCustomPages, } from '../utils'; +import { useWaitForComponentMount } from '../utils/useWaitForComponentMount'; +import { ClerkHostRenderer } from './ClerkHostRenderer'; import { withClerk } from './withClerk'; +type FallbackProp = { + /** + * An optional element to render while the component is mounting. + */ + fallback?: ReactNode; +}; + type UserProfileExportType = typeof _UserProfile & { Page: typeof UserProfilePage; Link: typeof UserProfileLink; @@ -59,8 +65,7 @@ type UserButtonExportType = typeof _UserButton & { Link: typeof MenuLink; /** * The `` component can be used in conjunction with `asProvider` in order to control rendering - * of the `` without affecting its configuration or any custom pages - * that could be mounted + * of the `` without affecting its configuration or any custom pages that could be mounted * @experimental This API is experimental and may change at any moment. */ __experimental_Outlet: typeof UserButtonOutlet; @@ -89,8 +94,7 @@ type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { OrganizationProfileLink: typeof OrganizationProfileLink; /** * The `` component can be used in conjunction with `asProvider` in order to control rendering - * of the `` without affecting its configuration or any custom pages - * that could be mounted + * of the `` without affecting its configuration or any custom pages that could be mounted * @experimental This API is experimental and may change at any moment. */ __experimental_Outlet: typeof OrganizationSwitcherOutlet; @@ -109,103 +113,6 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without< __experimental_asProvider?: boolean; }; -const isMountProps = (props: any): props is MountProps => { - return 'mount' in props; -}; - -const isOpenProps = (props: any): props is OpenProps => { - return 'open' in props; -}; - -// README: should be a class pure component in order for mount and unmount -// lifecycle props to be invoked correctly. Replacing the class component with a -// functional component wrapped with a React.memo is not identical to the original -// class implementation due to React intricacies such as the useEffect’s cleanup -// seems to run AFTER unmount, while componentWillUnmount runs BEFORE. - -// More information can be found at https://clerk.slack.com/archives/C015S0BGH8R/p1624891993016300 - -// The function Portal implementation is commented out for future reference. - -// const Portal = React.memo(({ props, mount, unmount }: MountProps) => { -// const portalRef = React.createRef(); - -// useEffect(() => { -// if (portalRef.current) { -// mount(portalRef.current, props); -// } -// return () => { -// if (portalRef.current) { -// unmount(portalRef.current); -// } -// }; -// }, []); - -// return
; -// }); - -// Portal.displayName = 'ClerkPortal'; - -class Portal extends React.PureComponent< - PropsWithChildren<(MountProps | OpenProps) & { hideRootHtmlElement?: boolean }> -> { - private portalRef = React.createRef(); - - componentDidUpdate(_prevProps: Readonly) { - if (!isMountProps(_prevProps) || !isMountProps(this.props)) { - return; - } - - // Remove children and customPages from props before comparing - // children might hold circular references which deepEqual can't handle - // and the implementation of customPages or customMenuItems relies on props getting new references - const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children'); - const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children'); - // instead, we simply use the length of customPages to determine if it changed or not - const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length; - const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length; - - if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { - if (this.portalRef.current) { - this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); - } - } - } - - componentDidMount() { - if (this.portalRef.current) { - if (isMountProps(this.props)) { - this.props.mount(this.portalRef.current, this.props.props); - } - - if (isOpenProps(this.props)) { - this.props.open(this.props.props); - } - } - } - - componentWillUnmount() { - if (this.portalRef.current) { - if (isMountProps(this.props)) { - this.props.unmount(this.portalRef.current); - } - if (isOpenProps(this.props)) { - this.props.close(); - } - } - } - - render() { - const { hideRootHtmlElement = false } = this.props; - return ( - <> - {!hideRootHtmlElement &&
} - {this.props.children} - - ); - } -} - const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { return ( <> @@ -215,27 +122,61 @@ const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { ); }; -export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'SignIn'); +export const SignIn = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; -export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'SignUp'); + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'SignIn', renderWhileLoading: true }, +); + +export const SignUp = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'SignUp', renderWhileLoading: true }, +); export function UserProfilePage({ children }: PropsWithChildren) { logErrorInDevMode(userProfilePageRenderedError); @@ -248,20 +189,37 @@ export function UserProfileLink({ children }: PropsWithChildren>>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp> & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); return ( - - - + <> + {shouldShowFallback && fallback} + + + + ); }, - 'UserProfile', + { component: 'UserProfile', renderWhileLoading: true }, ); export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { @@ -276,7 +234,19 @@ const UserButtonContext = createContext({ }); const _UserButton = withClerk( - ({ clerk, ...props }: WithClerkProp>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children, { allowForAnyChildren: !!props.__experimental_asProvider, }); @@ -297,18 +267,23 @@ const _UserButton = withClerk( return ( - - {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? sanitizedChildren : null} - - + {shouldShowFallback && fallback} + {clerk.loaded && ( + + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? sanitizedChildren : null} + + + )} ); }, - 'UserButton', + { component: 'UserButton', renderWhileLoading: true }, ); export function MenuItems({ children }: PropsWithChildren) { @@ -337,7 +312,7 @@ export function UserButtonOutlet(outletProps: Without; + return ; } export const UserButton: UserButtonExportType = Object.assign(_UserButton, { @@ -360,20 +335,39 @@ export function OrganizationProfileLink({ children }: PropsWithChildren>>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp> & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); return ( - - - + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + + + )} + ); }, - 'OrganizationProfile', + { component: 'OrganizationProfile', renderWhileLoading: true }, ); export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, { @@ -381,16 +375,33 @@ export const OrganizationProfile: OrganizationProfileExportType = Object.assign( Link: OrganizationProfileLink, }); -export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'CreateOrganization'); +export const CreateOrganization = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'CreateOrganization', renderWhileLoading: true }, +); const OrganizationSwitcherContext = createContext({ mount: () => {}, @@ -399,7 +410,19 @@ const OrganizationSwitcherContext = createContext({ }); const _OrganizationSwitcher = withClerk( - ({ clerk, ...props }: WithClerkProp>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children, { allowForAnyChildren: !!props.__experimental_asProvider, }); @@ -411,6 +434,8 @@ const _OrganizationSwitcher = withClerk( unmount: clerk.unmountOrganizationSwitcher, updateProps: (clerk as any).__unstable__updateProps, props: { ...props, organizationProfileProps }, + rootProps: rendererRootProps, + component, }; /** @@ -420,18 +445,23 @@ const _OrganizationSwitcher = withClerk( return ( - - {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? sanitizedChildren : null} - - + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? sanitizedChildren : null} + + + )} + ); }, - 'OrganizationSwitcher', + { component: 'OrganizationSwitcher', renderWhileLoading: true }, ); export function OrganizationSwitcherOutlet( @@ -447,7 +477,7 @@ export function OrganizationSwitcherOutlet( }, } satisfies MountProps; - return ; + return ; } export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { @@ -456,34 +486,86 @@ export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assig __experimental_Outlet: OrganizationSwitcherOutlet, }); -export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'OrganizationList'); +export const OrganizationList = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; -export const GoogleOneTap = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'GoogleOneTap'); + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; -export const Waitlist = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'Waitlist'); + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'OrganizationList', renderWhileLoading: true }, +); + +export const GoogleOneTap = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'GoogleOneTap', renderWhileLoading: true }, +); + +export const Waitlist = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'Waitlist', renderWhileLoading: true }, +); diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 54ff7be47c..b826b6494a 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -2,28 +2,32 @@ import type { LoadedClerk, Without } from '@clerk/types'; import React from 'react'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; -import { errorThrower } from '../errors/errorThrower'; -import { hocChildrenNotAFunctionError } from '../errors/messages'; import { useAssertWrappedByClerkProvider } from '../hooks/useAssertWrappedByClerkProvider'; -export const withClerk =

( +export const withClerk =

( Component: React.ComponentType

, - displayName?: string, + displayNameOrOptions?: string | { component: string; renderWhileLoading?: boolean }, ) => { - displayName = displayName || Component.displayName || Component.name || 'Component'; + const passedDisplayedName = + typeof displayNameOrOptions === 'string' ? displayNameOrOptions : displayNameOrOptions?.component; + const displayName = passedDisplayedName || Component.displayName || Component.name || 'Component'; Component.displayName = displayName; + + const options = typeof displayNameOrOptions === 'string' ? undefined : displayNameOrOptions; + const HOC = (props: Without) => { useAssertWrappedByClerkProvider(displayName || 'withClerk'); const clerk = useIsomorphicClerkContext(); - if (!clerk.loaded) { + if (!clerk.loaded && !options?.renderWhileLoading) { return null; } return ( ); @@ -31,19 +35,3 @@ export const withClerk =

( HOC.displayName = `withClerk(${displayName})`; return HOC; }; - -export const WithClerk: React.FC<{ - children: (clerk: LoadedClerk) => React.ReactNode; -}> = ({ children }) => { - const clerk = useIsomorphicClerkContext(); - - if (typeof children !== 'function') { - errorThrower.throw(hocChildrenNotAFunctionError); - } - - if (!clerk.loaded) { - return null; - } - - return <>{children(clerk as unknown as LoadedClerk)}; -}; diff --git a/packages/react/src/errors/messages.ts b/packages/react/src/errors/messages.ts index 359850c397..e63ce6979a 100644 --- a/packages/react/src/errors/messages.ts +++ b/packages/react/src/errors/messages.ts @@ -3,8 +3,6 @@ export const noClerkProviderError = 'You must wrap your application in a `You've passed multiple children components to <${name}/>. You can only pass a single child component or text.`; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index eb1326d8d7..0c732dbc1c 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -70,7 +70,7 @@ export interface HeadlessBrowserClerkConstructor { new (publishableKey: string, options?: DomainOrProxyUrl): HeadlessBrowserClerk; } -export type WithClerkProp = T & { clerk: LoadedClerk }; +export type WithClerkProp = T & { clerk: LoadedClerk; component?: string }; export interface CustomPortalsRendererProps { customPagesPortals?: any[]; diff --git a/packages/react/src/utils/useWaitForComponentMount.ts b/packages/react/src/utils/useWaitForComponentMount.ts new file mode 100644 index 0000000000..dc6019b65f --- /dev/null +++ b/packages/react/src/utils/useWaitForComponentMount.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Used to detect when a Clerk component has been added to the DOM. + */ +function waitForElementChildren(options: { selector?: string; root?: HTMLElement | null; timeout?: number }) { + const { root = document?.body, selector, timeout = 0 } = options; + + return new Promise((resolve, reject) => { + if (!root) { + reject(new Error('No root element provided')); + return; + } + + let elementToWatch: HTMLElement | null = root; + if (selector) { + elementToWatch = root?.querySelector(selector); + } + + // Check if the element already has child nodes + const isElementAlreadyPresent = elementToWatch?.childElementCount && elementToWatch.childElementCount > 0; + if (isElementAlreadyPresent) { + resolve(); + return; + } + + // Set up a MutationObserver to detect when the element has children + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + if (!elementToWatch && selector) { + elementToWatch = root?.querySelector(selector); + } + + if (elementToWatch?.childElementCount && elementToWatch.childElementCount > 0) { + observer.disconnect(); + resolve(); + return; + } + } + } + }); + + observer.observe(root, { childList: true, subtree: true }); + + // Set up an optional timeout to reject the promise if the element never gets child nodes + if (timeout > 0) { + setTimeout(() => { + observer.disconnect(); + reject(new Error(`Timeout waiting for element children`)); + }, timeout); + } + }); +} + +/** + * Detect when a Clerk component has mounted by watching DOM updates to an element with a `data-clerk-component="${component}"` property. + */ +export function useWaitForComponentMount(component?: string) { + const watcherRef = useRef>(); + const [status, setStatus] = useState<'rendering' | 'rendered' | 'error'>('rendering'); + + useEffect(() => { + if (!component) { + throw new Error('Clerk: no component name provided, unable to detect mount.'); + } + + if (typeof window !== 'undefined' && !watcherRef.current) { + watcherRef.current = waitForElementChildren({ selector: `[data-clerk-component="${component}"]` }) + .then(() => { + setStatus('rendered'); + }) + .catch(() => { + setStatus('error'); + }); + } + }, [component]); + + return status; +}