Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js): Render @clerk/ui #4114

Open
wants to merge 55 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
9e71651
update clerk/ui for rendering
BRKalow Sep 5, 2024
8f3ea4b
fix host router issue
BRKalow Sep 5, 2024
b3ee721
new ui renderer
BRKalow Sep 5, 2024
851f201
refactor component renderer, add sign up
BRKalow Sep 6, 2024
371f6ee
remove unused function
BRKalow Sep 6, 2024
b8d014f
add another comment
BRKalow Sep 6, 2024
56af4f5
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
BRKalow Sep 6, 2024
f0c6f51
tweak webpack chunk settings
BRKalow Sep 7, 2024
2fdd33e
fix bring back isSubmitting boolean
alexcarpenter Sep 10, 2024
5322329
update sideEffects to enable loading css file
alexcarpenter Sep 10, 2024
b9b3359
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
alexcarpenter Sep 10, 2024
e3132e3
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Sep 19, 2024
f000689
feat(clerk-js,types,shared): Add necessary types for new UI
dstaley Sep 24, 2024
9866b16
chore(repo): Add changeset
dstaley Sep 24, 2024
812035a
fix(clerk-js): Add dependency on @clerk/ui
dstaley Sep 24, 2024
da7ba53
chore(clerk-js): Temporarily increase bundlewatch limits
dstaley Sep 24, 2024
3fe0bda
chore(clerk-js): Temporarily increase bundlewatch limits
dstaley Sep 24, 2024
9542bab
fix(clerk-js): Ensure CJS script doesn't contain ESM code
dstaley Sep 24, 2024
a75375f
fix(ui): Mark package as public
dstaley Sep 24, 2024
7f71197
fix(nextjs): Pass correct router option
dstaley Sep 24, 2024
31d749c
fix(clerk-js): Emit CJS chunks as CJS
dstaley Sep 24, 2024
4b6c093
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Sep 24, 2024
86419e0
fix(clerk-js): Only instantiate new UI if Clerk is loaded
dstaley Sep 24, 2024
93c17d2
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Sep 24, 2024
e264672
fix(clerk-js): Remove scriptType config
dstaley Sep 25, 2024
4e84b25
fix(clerk-js): Exclude ui and elements package from ui-common chunk
dstaley Sep 25, 2024
4b50d86
fix(clerk-js): Consolidate wrapper and renderer into single chunk
dstaley Sep 25, 2024
02ac475
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Sep 25, 2024
c245d6f
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 1, 2024
2aab6cd
Merge branch 'refs/heads/main' into brk.feat/render-ui-in-clerk-js
nikosdouvlis Oct 2, 2024
8ab9b83
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
nikosdouvlis Oct 2, 2024
368db81
fix(clerk-js): Remove duplicate dependencies
dstaley Oct 2, 2024
b40c293
fix(clerk-js): Add types for mount call
dstaley Oct 2, 2024
fc3f448
fix(clerk-js): Remove duplicate imports
dstaley Oct 2, 2024
d415f19
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 2, 2024
477ee05
fix(clerk-js): Pass __experimental_router to pages router provider
dstaley Oct 2, 2024
860897c
feat(ui): Add `<RouterLink />` component (#4277)
alexcarpenter Oct 3, 2024
2cc9acd
chore(elements): Align clerk dependencies (#4282)
panteliselef Oct 4, 2024
07a26ab
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 4, 2024
77bd3e0
feat(types,ui): Add support for `__experimental.appearance` prop (#4290)
dstaley Oct 4, 2024
6bae6c3
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 4, 2024
3d6d9ae
fix(nextjs): Do not export useNextRouter
dstaley Oct 4, 2024
74c81bf
feat(clerk-js): Add support for loading UI styles as first CSS styles…
dstaley Oct 7, 2024
63f97d3
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 7, 2024
4746fb4
fix(clerk-js): Ensure import.meta.url isn't used in CJS build
dstaley Oct 7, 2024
54a836c
fix(clerk-js): Restore minimization
dstaley Oct 7, 2024
29258d0
fix(clerk-js): Bundle CSS into CJS and ESM bundles (#4301)
dstaley Oct 8, 2024
c65ea96
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 8, 2024
bab0ad3
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
BRKalow Oct 11, 2024
129bc20
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
jacekradko Oct 11, 2024
98fb7db
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 15, 2024
62dbb34
fix(ui): Fix package.json
dstaley Oct 15, 2024
dc4149e
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 15, 2024
163ef01
fix(elements): Move shared back to dependencies
dstaley Oct 16, 2024
5035d98
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
dstaley Oct 16, 2024
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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/clerk-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@clerk/localizations": "2.8.1",
"@clerk/shared": "2.6.2",
"@clerk/types": "4.19.0",
"@clerk/ui": "^0.1.8",
"@coinbase/wallet-sdk": "4.0.4",
"@emotion/cache": "11.11.0",
"@emotion/react": "11.11.1",
Expand Down
54 changes: 36 additions & 18 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
stripScheme,
} from '@clerk/shared';
import { logger } from '@clerk/shared/logger';
import type { ClerkHostRouter } from '@clerk/shared/router';
import { eventPrebuiltComponentMounted, TelemetryCollector } from '@clerk/shared/telemetry';
import type {
__experimental_UserVerificationModalProps,
Expand Down Expand Up @@ -65,6 +66,7 @@ import type {
} from '@clerk/types';

import type { MountComponentRenderer } from '../ui/Components';
import { UI } from '../ui/new';
import {
ALLOWED_PROTOCOLS,
buildURL,
Expand Down Expand Up @@ -147,6 +149,8 @@ const defaultOptions: ClerkOptions = {
};

export class Clerk implements ClerkInterface {
// @ts-expect-error -- TODO: ensure defined
public ui: UI;
public static mountComponentRenderer?: MountComponentRenderer;

public static version: string = __PKG_VERSION__;
Expand Down Expand Up @@ -182,6 +186,7 @@ export class Clerk implements ClerkInterface {
#options: ClerkOptions = {};
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#router: ClerkHostRouter;

get publishableKey(): string {
return this.#publishableKey;
Expand Down Expand Up @@ -308,6 +313,11 @@ export class Clerk implements ClerkInterface {
});
}

// @ts-expect-error -- TODO: type
this.#router = this.#options.router;
// @ts-expect-error -- TODO: type
this.ui = new UI({ router: this.#options.router, clerk: this, options: this.#options });

this.#options.allowedRedirectOrigins = createAllowedRedirectOrigins(
this.#options.allowedRedirectOrigins,
this.frontendApi,
Expand Down Expand Up @@ -496,15 +506,19 @@ export class Clerk implements ClerkInterface {
};

public mountSignIn = (node: HTMLDivElement, props?: SignInProps): void => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted({ preloadHint: 'SignIn' }).then(controls =>
controls.mountComponent({
name: 'SignIn',
appearanceKey: 'signIn',
node,
props,
}),
);
if (props.experimental?.newComponents) {
this.ui.mount('SignIn', node, props);
} else {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted({ preloadHint: 'SignIn' }).then(controls =>
controls.mountComponent({
name: 'SignIn',
appearanceKey: 'signIn',
node,
props,
}),
);
}
this.telemetry?.record(eventPrebuiltComponentMounted('SignIn', props));
};

Expand Down Expand Up @@ -550,15 +564,19 @@ export class Clerk implements ClerkInterface {
};

public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls =>
controls.mountComponent({
name: 'SignUp',
appearanceKey: 'signUp',
node,
props,
}),
);
if (props.experimental?.newComponents) {
this.ui.mount('SignUp', node, props);
} else {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls =>
controls.mountComponent({
name: 'SignUp',
appearanceKey: 'signUp',
node,
props,
}),
);
}
this.telemetry?.record(eventPrebuiltComponentMounted('SignUp', props));
};

Expand Down
99 changes: 99 additions & 0 deletions packages/clerk-js/src/ui/new/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createDeferredPromise } from '@clerk/shared';
import { ClerkInstanceContext, OptionsContext } from '@clerk/shared/react';
import type { ClerkHostRouter } from '@clerk/shared/router';
import { ClerkHostRouterContext } from '@clerk/shared/router';
import type { ClerkOptions, LoadedClerk } from '@clerk/types';
import type { ComponentType, ReactNode } from 'react';

import type { init } from './renderer';
import type { ComponentDefinition } from './types';

type $TODO = any;
dstaley marked this conversation as resolved.
Show resolved Hide resolved

export class UI {
router: ClerkHostRouter;
clerk: LoadedClerk;
options: ClerkOptions;
componentRegistry = new Map<string, ComponentDefinition>();

#rendererPromise?: ReturnType<typeof createDeferredPromise>;
#renderer?: ReturnType<typeof init>;
#wrapper: ComponentType<{ children: ReactNode }>;

constructor({ router, clerk, options }: { router: ClerkHostRouter; clerk: LoadedClerk; options: ClerkOptions }) {
this.router = router;
this.clerk = clerk;
this.options = options;

// register components
this.register('SignIn', {
type: 'component',
load: () =>
import(/* webpackChunkName: "rebuild--sign-in" */ '@clerk/ui/sign-in').then(({ SignIn }) => ({
default: SignIn,
})),
});
this.register('SignUp', {
type: 'component',
load: () =>
import(/* webpackChunkName: "rebuild--sign-up" */ '@clerk/ui/sign-up').then(({ SignUp }) => ({
default: SignUp,
})),
});

this.#wrapper = ({ children }) => {
return (
<ClerkInstanceContext.Provider value={{ value: this.clerk }}>
<OptionsContext.Provider value={this.options}>
<ClerkHostRouterContext.Provider value={this.router}>{children}</ClerkHostRouterContext.Provider>
</OptionsContext.Provider>
</ClerkInstanceContext.Provider>
);
};
}

// Mount a component from the registry
mount(componentName: string, node: HTMLElement, props: $TODO) {
const component = this.componentRegistry.get(componentName);
if (!component) {
throw new Error(`clerk/ui: Unable to find component definition for ${componentName}`);
}

// immediately start loading the component
component.load();

this.renderer()
.then(() => {
this.#renderer?.mount(this.#renderer.createElementFromComponentDefinition(component), props, node);
})
.catch(err => {
console.error(`clerk/ui: Error mounting component ${componentName}:`, err);
});
}

unmount(node: HTMLElement) {
this.#renderer?.unmount(node);
}

// Registers a component for rendering later
register(componentName: string, componentDefinition: ComponentDefinition) {
this.componentRegistry.set(componentName, componentDefinition);
}

renderer() {
if (this.#rendererPromise) {
return this.#rendererPromise.promise;
}

this.#rendererPromise = createDeferredPromise();

import('./renderer').then(({ init }) => {
this.#renderer = init({
wrapper: this.#wrapper,
});
this.#rendererPromise?.resolve();
});

return this.#rendererPromise.promise;
}
}
63 changes: 63 additions & 0 deletions packages/clerk-js/src/ui/new/renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// TODO: don't import here
import '@clerk/ui/styles.css';

import type { ElementType } from 'react';
import { createElement, lazy } from 'react';
import { createPortal } from 'react-dom';
import { createRoot } from 'react-dom/client';

import type { ComponentDefinition } from './types';

const ROOT_ELEMENT_ID = 'clerk-components-new';

// Initializes the react renderer
export function init({ wrapper }: { wrapper: ElementType }) {
const renderedComponents = new Map<HTMLElement, [ElementType, Record<string, any>]>();
let rootElement = document.getElementById(ROOT_ELEMENT_ID);

if (!rootElement) {
rootElement = document.createElement('div');
rootElement.setAttribute('id', 'clerk-components');
document.body.appendChild(rootElement);
}

const root = createRoot(rootElement);

// (re-)renders the render wrapper, rendering any components present in the `renderedComponents` map.
// React's render function retains state, so it's safe to call multiple times as additional components are mounted and unmounted.
function render() {
root.render(
createElement(
wrapper,
null,
Array.from(renderedComponents.entries()).map(([node, [element, props]]) =>
createPortal(createElement(element, props), node),
),
),
);
}

function mount(element: ElementType, props: any, node: HTMLElement) {
Copy link
Member

Choose a reason for hiding this comment

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

Just wanted to point out here that after this refactor we're probably losing the ability to await the mount call.

This is something not blocking this PR at the moment, but I'd like to verify if that's the case and see if we actually need to ever await it or not

renderedComponents.set(node, [element, props]);
render();
}

function unmount(node: HTMLElement) {
if (!renderedComponents.has(node)) {
return;
}

renderedComponents.delete(node);
render();
}

function createElementFromComponentDefinition(componentDefinition: ComponentDefinition) {
return lazy(componentDefinition.load);
}

return {
mount,
unmount,
createElementFromComponentDefinition,
};
}
6 changes: 6 additions & 0 deletions packages/clerk-js/src/ui/new/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ComponentType } from 'react';

export interface ComponentDefinition {
type: 'component' | 'modal';
load: () => Promise<{ default: ComponentType }>;
}
21 changes: 21 additions & 0 deletions packages/clerk-js/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const variantToSourceFile = {

/** @returns { import('webpack').Configuration } */
const common = ({ mode }) => {
/** @type { import('webpack').Configuration } */
return {
mode,
resolve: {
Expand Down Expand Up @@ -77,9 +78,27 @@ const common = ({ mode }) => {
name: 'vendors',
priority: -10,
},
commonNew: {
minChunks: 2,
name: 'common-new',
chunks(chunk) {
return chunk.name?.startsWith('rebuild--');
},
priority: 0,
},
react: {
chunks: 'all',
test: /[\\/]node_modules[\\/](react-dom|scheduler)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
},
},
},
experiments: {
css: true,
},
};
};

Expand Down Expand Up @@ -164,6 +183,8 @@ const commonForProd = () => {
new TerserPlugin({
terserOptions: {
compress: {
unused: true,
dead_code: true,
passes: 2,
},
mangle: {
Expand Down
1 change: 1 addition & 0 deletions packages/elements/src/internals/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const ERROR_CODES = {
export const ROUTING = {
path: 'path',
virtual: 'virtual',
hash: 'hash',
} as const;

export type ROUTING = (typeof ROUTING)[keyof typeof ROUTING];
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const handleRedirectCallback = fromCallback<AnyEventObject, HandleRedirec

// @ts-expect-error - Clerk types are incomplete
// TODO: Update local Clerk types
const loadedClerk = clerk.clerkjs as LoadedClerk;
const loadedClerk = (clerk.clerkjs ?? clerk) as LoadedClerk;

void loadedClerk.handleRedirectCallback(
{
Expand Down
11 changes: 5 additions & 6 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react';
import type { ClerkHostRouter } from '@clerk/shared/router';
import { ClerkHostRouterContext } from '@clerk/shared/router';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import React, { useEffect, useTransition } from 'react';

import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';
Expand Down Expand Up @@ -34,9 +34,6 @@ export const NEXT_WINDOW_HISTORY_SUPPORT_VERSION = '14.1.0';
*/
export const useNextRouter = (): ClerkHostRouter => {
const router = useRouter();
const pathname = usePathname();
// eslint-disable-next-line react-hooks/rules-of-hooks -- The order doesn't differ between renders as we're checking the execution environment.
const searchParams = typeof window === 'undefined' ? new URLSearchParams() : useSearchParams();

// The window.history APIs seem to prevent Next.js from triggering a full page re-render, allowing us to
// preserve internal state between steps.
Expand All @@ -52,8 +49,8 @@ export const useNextRouter = (): ClerkHostRouter => {
shallowPush(path: string) {
canUseWindowHistoryAPIs ? window.history.pushState(null, '', path) : router.push(path, {});
},
pathname: () => pathname,
searchParams: () => searchParams,
pathname: () => window.location.pathname,
searchParams: () => new URLSearchParams(window.location.search),
dstaley marked this conversation as resolved.
Show resolved Hide resolved
};
};

Expand Down Expand Up @@ -113,6 +110,8 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {

const mergedProps = mergeNextClerkPropsWithEnv({
...props,
// @ts-expect-error -- TODO: type
router: clerkRouter,
routerPush: push,
routerReplace: replace,
});
Expand Down
Loading
Loading