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-react,nextjs): Speed up clerk-js loading by using a <script/> tag #3156

Merged
merged 8 commits into from
Apr 15, 2024
9 changes: 9 additions & 0 deletions .changeset/serious-balloons-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
---

Speed up loading of clerk-js by using a `<script/>` tag when html is generated.
This is supported during SSR, SSG in
- Next.js Pages Router
- Next.js App Router
6 changes: 5 additions & 1 deletion packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, { useEffect, useTransition } from 'react';
import { ClerkNextOptionsProvider } from '../../client-boundary/NextOptionsContext';
import { useSafeLayoutEffect } from '../../client-boundary/useSafeLayoutEffect';
import type { NextClerkProviderProps } from '../../types';
import { ClerkJSScript } from '../../utils/clerk-js-script';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { useAwaitableNavigate } from './useAwaitableNavigate';

Expand Down Expand Up @@ -73,7 +74,10 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {

return (
<ClerkNextOptionsProvider options={mergedProps}>
<ReactClerkProvider {...mergedProps}>{children}</ReactClerkProvider>
<ReactClerkProvider {...mergedProps}>
<ClerkJSScript router='app' />
{children}
</ReactClerkProvider>
</ClerkNextOptionsProvider>
);
};
3 changes: 3 additions & 0 deletions packages/nextjs/src/pages/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import React from 'react';
import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext';
import { useSafeLayoutEffect } from '../client-boundary/useSafeLayoutEffect';
import type { NextClerkProviderProps } from '../types';
import { ClerkJSScript } from '../utils/clerk-js-script';
import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache';
import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv';

setErrorThrowerOptions({ packageName: PACKAGE_NAME });

export function ClerkProvider({ children, ...props }: NextClerkProviderProps): JSX.Element {
Expand Down Expand Up @@ -45,6 +47,7 @@ export function ClerkProvider({ children, ...props }: NextClerkProviderProps): J
{...mergedProps}
initialState={initialState}
>
<ClerkJSScript router='pages' />
{children}
</ReactClerkProvider>
</ClerkNextOptionsProvider>
Expand Down
47 changes: 47 additions & 0 deletions packages/nextjs/src/utils/clerk-js-script.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useClerk } from '@clerk/clerk-react';
import { buildClerkJsScriptAttributes, clerkJsScriptUrl } from '@clerk/clerk-react/internal';
import NextScript from 'next/script';
import React from 'react';

import { useClerkNextOptions } from '../client-boundary/NextOptionsContext';

type ClerkJSScriptProps = {
router: 'app' | 'pages';
};

function ClerkJSScript(props: ClerkJSScriptProps) {
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant } = useClerkNextOptions();
const { domain, proxyUrl } = useClerk();
const options = {
domain,
proxyUrl,
publishableKey: publishableKey!,
clerkJSUrl,
clerkJSVersion,
clerkJSVariant,
};
const scriptUrl = clerkJsScriptUrl(options);

/**
* Notes:
* `next/script` in 13.x.x when used with App Router will fail to pass any of our `data-*` attributes, resulting in errors
* Nextjs App Router will automatically move inline scripts inside `<head/>`
* Using the `nextjs/script` for App Router with the `beforeInteractive` strategy will throw an error because our custom script will be mounted outside the `html` tag.
*/
const Script = props.router === 'app' ? 'script' : NextScript;

return (
<Script
src={scriptUrl}
data-clerk-js-script
async
// `nextjs/script` will add defer by default and does not get removed when we async is true
defer={props.router === 'pages' ? false : undefined}
crossOrigin='anonymous'
strategy={props.router === 'pages' ? 'beforeInteractive' : undefined}
{...buildClerkJsScriptAttributes(options)}
/>
);
}

export { ClerkJSScript };
2 changes: 2 additions & 0 deletions packages/react/src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useRoutingProps } from './hooks/useRoutingProps';

export { clerkJsScriptUrl, buildClerkJsScriptAttributes } from './utils/loadClerkJsScript';
49 changes: 38 additions & 11 deletions packages/react/src/utils/loadClerkJsScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,31 @@ import { versionSelector } from './versionSelector';
const FAILED_TO_LOAD_ERROR = 'Clerk: Failed to load Clerk';

type LoadClerkJsScriptOptions = Omit<IsomorphicClerkOptions, 'proxyUrl' | 'domain'> & {
proxyUrl: string;
domain: string;
proxyUrl?: string;
domain?: string;
};

export const loadClerkJsScript = (opts: LoadClerkJsScriptOptions) => {
const loadClerkJsScript = (opts: LoadClerkJsScriptOptions) => {
const { publishableKey } = opts;

if (!publishableKey) {
errorThrower.throwMissingPublishableKeyError();
}

const existingScript = document.querySelector<HTMLScriptElement>('script[data-clerk-js-script]');

if (existingScript) {
return new Promise((resolve, reject) => {
existingScript.addEventListener('load', () => {
resolve(existingScript);
});

existingScript.addEventListener('error', () => {
reject(FAILED_TO_LOAD_ERROR);
});
});
}

Comment on lines +25 to +38
Copy link
Member Author

Choose a reason for hiding this comment

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

This also allows anyone to add a script without waiting for an official support.

return loadScript(clerkJsScriptUrl(opts), {
async: true,
crossOrigin: 'anonymous',
Expand Down Expand Up @@ -52,17 +66,30 @@ const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.${variant}browser.js`;
};

const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
const { publishableKey, proxyUrl, domain } = options;
if (publishableKey) {
script.setAttribute('data-clerk-publishable-key', publishableKey);
const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
const obj: Record<string, string> = {};

if (options.publishableKey) {
obj['data-clerk-publishable-key'] = options.publishableKey;
}

if (proxyUrl) {
script.setAttribute('data-clerk-proxy-url', proxyUrl);
if (options.proxyUrl) {
obj['data-clerk-proxy-url'] = options.proxyUrl;
}

if (domain) {
script.setAttribute('data-clerk-domain', domain);
if (options.domain) {
obj['data-clerk-clerk-domain'] = options.domain;
}

return obj;
};

const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
const attributes = buildClerkJsScriptAttributes(options);
for (const attribute in attributes) {
script.setAttribute(attribute, attributes[attribute]);
}
};

export { loadClerkJsScript, buildClerkJsScriptAttributes, clerkJsScriptUrl };
export type { LoadClerkJsScriptOptions };
Loading