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;
+}