Skip to content
6 changes: 6 additions & 0 deletions .changeset/floppy-glasses-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/clerk-react': patch
---

Wait for pricing table data to be ready before hiding its fallback.
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const PricingTableRoot = (props: PricingTableProps) => {
return (
<Flow.Root
flow='pricingTable'
isFlowReady={clerk.isSignedIn ? !!subscription : plans.length > 0}
sx={{
width: '100%',
}}
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/customizables/Flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { descriptors } from './index';

type FlowRootProps = React.PropsWithChildren & FlowMetadata & { sx?: ThemableCssProp };

const Root = (props: FlowRootProps) => {
const Root = (props: FlowRootProps & { isFlowReady?: boolean }) => {
return (
<FlowMetadataProvider flow={props.flow}>
<InternalThemeProvider>
Expand Down
8 changes: 5 additions & 3 deletions packages/clerk-js/src/ui/elements/InvisibleRootBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { makeCustomizable } from '../customizables/makeCustomizable';

type RootBoxProps = React.PropsWithChildren<{ className: string }>;

const _InvisibleRootBox = React.memo((props: RootBoxProps) => {
const _InvisibleRootBox = React.memo((props: RootBoxProps & { isFlowReady?: boolean }) => {
const [showSpan, setShowSpan] = React.useState(true);
const parentRef = React.useRef<HTMLElement | null>(null);

Expand All @@ -16,8 +16,10 @@ const _InvisibleRootBox = React.memo((props: RootBoxProps) => {
if (showSpan) {
setShowSpan(false);
}
parent.className = props.className;
}, [props.className]);

parent.setAttribute('class', props.className);
parent.setAttribute('data-component-status', props.isFlowReady ? 'ready' : 'awaiting-data');
}, [props.className, props.isFlowReady]);

return (
<>
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook<FlowMetadata>('F

export const FlowMetadataProvider = (props: React.PropsWithChildren<FlowMetadata>) => {
const { flow, part } = props;
const value = React.useMemo(() => ({ value: props }), [flow, part]);
const value = React.useMemo(() => ({ value: { ...props } }), [flow, part]);
return <FlowMetadataCtx.Provider value={value}>{props.children}</FlowMetadataCtx.Provider>;
};

Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/components/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,10 @@ export const Waitlist = withClerk(

export const PricingTable = withClerk(
({ clerk, component, fallback, ...props }: WithClerkProp<PricingTableProps & FallbackProp>) => {
const mountingStatus = useWaitForComponentMount(component);
const mountingStatus = useWaitForComponentMount(component, {
// This attribute is added to the PricingTable root element after we've successfully fetched the plans asynchronously.
selector: '[data-component-status="ready"]',
});
const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded;

const rendererRootProps = {
Expand Down
106 changes: 64 additions & 42 deletions packages/react/src/utils/useWaitForComponentMount.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,77 @@
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;
const createAwaitableMutationObserver = (
globalOptions: MutationObserverInit & {
isReady: (el: HTMLElement | null, selector: string) => boolean;
},
) => {
const isReady = globalOptions?.isReady;

return new Promise<void>((resolve, reject) => {
if (!root) {
reject(new Error('No root element provided'));
return;
}
return (options: { selector: string; root?: HTMLElement | null; timeout?: number }) =>
new Promise<void>((resolve, reject) => {
const { root = document?.body, selector, timeout = 0 } = options;

let elementToWatch: HTMLElement | null = root;
if (selector) {
elementToWatch = root?.querySelector(selector);
}
if (!root) {
reject(new Error('No root element provided'));
return;
}

// Check if the element already has child nodes
const isElementAlreadyPresent = elementToWatch?.childElementCount && elementToWatch.childElementCount > 0;
if (isElementAlreadyPresent) {
resolve();
return;
}
let elementToWatch: HTMLElement | null = root;
if (selector) {
elementToWatch = root?.querySelector(selector);
}

// 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') {
// Initial readiness check
if (isReady(elementToWatch, selector)) {
resolve();
return;
}

// Set up a MutationObserver to detect when the element has children
const observer = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
if (!elementToWatch && selector) {
elementToWatch = root?.querySelector(selector);
}

if (elementToWatch?.childElementCount && elementToWatch.childElementCount > 0) {
observer.disconnect();
resolve();
return;
if (
(globalOptions.childList && mutation.type === 'childList') ||
(globalOptions.attributes && mutation.type === 'attributes')
) {
if (isReady(elementToWatch, selector)) {
observer.disconnect();
resolve();
return;
}
}
}
});

observer.observe(root, globalOptions);

// 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 ${selector}`));
}, timeout);
}
});
};

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);
}
});
}
const waitForElementChildren = createAwaitableMutationObserver({
childList: true,
subtree: true,
isReady: (el, selector) => !!el?.childElementCount && el?.matches?.(selector) && el.childElementCount > 0,
});

/**
* 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) {
export function useWaitForComponentMount(
component?: string,
options?: { selector: string },
): 'rendering' | 'rendered' | 'error' {
const watcherRef = useRef<Promise<void>>();
const [status, setStatus] = useState<'rendering' | 'rendered' | 'error'>('rendering');

Expand All @@ -66,15 +81,22 @@ export function useWaitForComponentMount(component?: string) {
}

if (typeof window !== 'undefined' && !watcherRef.current) {
watcherRef.current = waitForElementChildren({ selector: `[data-clerk-component="${component}"]` })
const defaultSelector = `[data-clerk-component="${component}"]`;
const selector = options?.selector;
watcherRef.current = waitForElementChildren({
selector: selector
? // Allows for `[data-clerk-component="xxxx"][data-some-attribute="123"] .my-class`
defaultSelector + selector
: defaultSelector,
Comment on lines +87 to +90
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to join with a ' '? Or intentionally not?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concatenation happens between [data-clerk-component="xxxx"] and [data-some-attribute="123"] .my-class, and between those there is no space.

})
.then(() => {
setStatus('rendered');
})
.catch(() => {
setStatus('error');
});
}
}, [component]);
}, [component, options?.selector]);

return status;
}
Loading