Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(ui): extract reusable Section components from profile pages by alexcarpenter · Pull Request #9132 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
#moduleManager = new ModuleManager();

/**
* Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
* from the CDN with its own inlined @clerk/shared, so plain property access
* is the only channel that reliably crosses that boundary. This getter is
* how IsomorphicClerk forwards the manager to consumers that import
* @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
*
* @internal
*/
get __internal_moduleManager(): ModuleManager {
return this.#moduleManager;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
Expand DownExpand Up@@ -558,7 +572,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
new ModuleManager(),
this.#moduleManager,
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});

// Regression: composed/subcomponent UserProfile reads moduleManager via
// `useClerk().__internal_moduleManager`. `useClerk()` returns the
// IsomorphicClerk wrapper, so its getter must chain through to the loaded
// clerk-js's own `__internal_moduleManager`. This plain property access is
// the cross-bundle channel: clerk-js ships standalone from the CDN with its
// own inlined @clerk/shared, so module-scoped state cannot bridge the two.
//
// Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
// Stripe, zxcvbn) falls back to a rejecting manager.
it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
const mm = { import: vi.fn(() => Promise.resolve(undefined)) };

// Before clerk-js loads, the getter is undefined so readers fall back.
expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();

const innerClerk: any = {
addListener: vi.fn(),
__internal_moduleManager: mm,
};
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);

expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
});

it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
Expand DownExpand Up@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};

/**
* Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
* before clerk-js has loaded; composed UI components read this getter
* (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
* rejecting manager while it is `undefined`.
*
* @internal
*/
public get __internal_moduleManager(): ModuleManager | undefined {
return this.clerkjs?.__internal_moduleManager;
}

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
38 changes: 35 additions & 3 deletions packages/shared/src/types/clerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import type { ClerkGlobalHookError } from '../errors/globalHookError';
import type { ClerkGlobalHookError } from '@/errors/globalHookError';

import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand DownExpand Up@@ -139,11 +141,13 @@ export type SDKMetadata = {

/**
* A callback function that is called when Clerk resources change.
*
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
*
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
Expand DownExpand Up@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {

/**
* A callback that runs after sign out completes.
* @inline */
*
@inline */
export type SignOutCallback = () => void | Promise<any>;

/**
Expand DownExpand Up@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

/**
* Internal handle to the bundled ModuleManager. Exposed so framework SDK
* wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
* that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
* Plain property access crosses the bundle boundary that other channels
* cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
* invisible to consumers loading @clerk/shared from node_modules. It is
* `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
* readers must handle the absent case.
*
* @internal
*/
__internal_moduleManager: ModuleManager | undefined;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand All@@ -317,6 +336,7 @@ export interface Clerk {

/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
*
* @inline
*/
isStandardBrowser: boolean | undefined;
Expand DownExpand Up@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
*
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
Expand DownExpand Up@@ -398,6 +419,7 @@ export interface Clerk {

/**
* Closes the Clerk Checkout drawer.
*
* @hidden
*/
__internal_closeCheckout: () => void;
Expand All@@ -412,6 +434,7 @@ export interface Clerk {

/**
* Closes the Clerk PlanDetails drawer.
*
* @hidden
*/
__internal_closePlanDetails: () => void;
Expand All@@ -426,6 +449,7 @@ export interface Clerk {

/**
* Closes the Clerk SubscriptionDetails drawer.
*
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
Expand All@@ -440,12 +464,14 @@ export interface Clerk {

/**
* Closes the Clerk user verification modal.
*
* @hidden
*/
__internal_closeReverification: () => void;

/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
*
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
Expand All@@ -454,12 +480,14 @@ export interface Clerk {

/**
* Opens the Clerk Enable Organizations prompt for development instance
*
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;

/**
* Closes the Clerk Enable Organizations modal.
*
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
Expand DownExpand Up@@ -939,12 +967,14 @@ export interface Clerk {

/**
* Returns the configured `afterSignInUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;

/**
* Returns the configured `afterSignUpUrl` of the instance.
*
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
Expand DownExpand Up@@ -1100,6 +1130,7 @@ export interface Clerk {

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
*
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
Expand DownExpand Up@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
*
* @default true
*/
polling?: boolean;
Expand All@@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
*/
supportEmail?: string;
/**
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
* By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior.
*/
touchSession?: boolean;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground })}
sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
import { useOrganization } from '@clerk/shared/react';

import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { ProfileCard } from '@/ui/elements/ProfileCard';
import { ProfileSection } from '@/ui/elements/Section';

import { Protect, useProtect } from '../../common';
import { useEnvironment } from '../../contexts';
import { Col, descriptors, localizationKeys, Text } from '../../customizables';
import { Col, localizationKeys, Text } from '../../customizables';
import { Action } from '../../elements/Action';
import { useActionContext } from '../../elements/Action/ActionRoot';
import { DeleteOrganizationForm, LeaveOrganizationForm } from './ActionConfirmationPage';
Expand DownExpand Up@@ -58,34 +57,28 @@ const DeleteOrganizationScreen = () => {
export const OrganizationGeneralPage = () => {
return (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
<ProfileCard.PagePanel
pageId='organizationGeneral'
titleKey={localizationKeys('organizationProfile.start.headerTitle__general')}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationGeneral')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.start.headerTitle__general')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</Col>
</Col>
<OrganizationProfileSection />
<Protect permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Protect>
<OrganizationLeaveSection />
<OrganizationDeleteSection />
</ProfileCard.PagePanel>
</ProfileCard.Page>
);
};

const OrganizationProfileSection = () => {
/**
* Renders the organization profile section (name, logo) with inline edit when the user has
* `org:sys_profile:manage`.
*
* @returns The profile section, or `null` when no organization is active.
*/
export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};

const OrganizationDomainsSection = () => {
/**
* Renders the verified-domains section.
*
* @returns The domains section, or `null` when domains are disabled, no organization is active, or
* there are no domains and the user cannot add any.
*/
export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
Expand DownExpand Up@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};

const OrganizationLeaveSection = () => {
/**
* Renders the "leave organization" action in the danger section.
*
* @returns The leave-organization section, or `null` when no organization is active.
*/
export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();

if (!organization) {
Expand DownExpand Up@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};

const OrganizationDeleteSection = () => {
/**
* Renders the "delete organization" action in the danger section.
*
* @returns The delete-organization section, or `null` when no organization is active, the user
* lacks `org:sys_profile:delete`, or admin delete is disabled.
*/
export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
<Col
elementDescriptor={descriptors.page}
gap={2}
sx={{ isolation: 'isolate' }}
>
<Col
elementDescriptor={descriptors.profilePage}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/UserProfile/APIKeysPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ export const APIKeysPage = () => {
<Col
gap={4}
elementDescriptor={descriptors.page}
sx={{ isolation: 'isolate' }}
>
<Header.Root>
<Header.Title
Expand Down
Loading
Loading