diff --git a/.changeset/late-pigs-study.md b/.changeset/late-pigs-study.md new file mode 100644 index 00000000000..0b521cd1d32 --- /dev/null +++ b/.changeset/late-pigs-study.md @@ -0,0 +1,5 @@ +--- +"@clerk/astro": minor +--- + +Add support for Astro View Transitions diff --git a/integration/templates/astro-node/src/layouts/ViewTransitionsLayout.astro b/integration/templates/astro-node/src/layouts/ViewTransitionsLayout.astro new file mode 100644 index 00000000000..a01199898b7 --- /dev/null +++ b/integration/templates/astro-node/src/layouts/ViewTransitionsLayout.astro @@ -0,0 +1,27 @@ +--- +import { ViewTransitions } from 'astro:transitions'; + +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} + + + +
+ +
+ + diff --git a/integration/templates/astro-node/src/pages/transitions/index.astro b/integration/templates/astro-node/src/pages/transitions/index.astro new file mode 100644 index 00000000000..a9465f5eb46 --- /dev/null +++ b/integration/templates/astro-node/src/pages/transitions/index.astro @@ -0,0 +1,15 @@ +--- +import { SignedIn, SignedOut, UserButton } from "@clerk/astro/components"; +import Layout from "../../layouts/ViewTransitionsLayout.astro"; +--- + + +
+ + Sign in + + + + +
+
diff --git a/integration/templates/astro-node/src/pages/transitions/sign-in.astro b/integration/templates/astro-node/src/pages/transitions/sign-in.astro new file mode 100644 index 00000000000..5afdd5a3489 --- /dev/null +++ b/integration/templates/astro-node/src/pages/transitions/sign-in.astro @@ -0,0 +1,10 @@ +--- +import { SignIn } from "@clerk/astro/components"; +import Layout from "../../layouts/ViewTransitionsLayout.astro"; +--- + + +
+ +
+
diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index 8f9b3f9dfd3..ed88f2a29e4 100644 --- a/integration/tests/astro/components.test.ts +++ b/integration/tests/astro/components.test.ts @@ -451,4 +451,34 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f await u.po.expect.toBeSignedIn(); await expect(u.page.getByText('Not a member')).toBeVisible(); }); + + test('renders components and keep internal routing behavior when view transitions is enabled', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/transitions'); + // Navigate to sign-in page using link to simulate transition + await u.page.getByRole('link', { name: /Sign in/i }).click(); + + // Components should be mounted on the new document + // when navigating through links + await u.page.waitForURL(`${app.serverUrl}/transitions/sign-in`); + await u.po.signIn.waitForMounted(); + + await u.po.signIn.setIdentifier(fakeAdmin.email); + await u.po.signIn.continue(); + await u.page.waitForURL(`${app.serverUrl}/transitions/sign-in#/factor-one`); + + await u.po.signIn.setPassword(fakeAdmin.password); + await u.po.signIn.continue(); + + await u.po.expect.toBeSignedIn(); + + // Internal Clerk routing should still work + await u.page.waitForURL(`${app.serverUrl}/transitions`); + + // Components should be rendered on hard reload + await u.po.userButton.waitForMounted(); + }); }); diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index b640dedbbf4..4505b0d939d 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -109,8 +109,31 @@ function createIntegration() 'page', ` ${command === 'dev' ? `console.log("${packageName}","Initialize Clerk: page")` : ''} - import { runInjectionScript } from "${buildImportPath}"; - await runInjectionScript(${JSON.stringify(internalParams)});`, + import { runInjectionScript, swapDocument } from "${buildImportPath}"; + import { navigate, transitionEnabledOnThisPage } from "astro:transitions/client"; + + if (transitionEnabledOnThisPage()) { + document.addEventListener('astro:before-swap', (e) => { + const clerkComponents = document.querySelector('#clerk-components'); + // Keep the div element added by Clerk + if (clerkComponents) { + const clonedEl = clerkComponents.cloneNode(true); + e.newDocument.body.appendChild(clonedEl); + } + + e.swap = () => swapDocument(e.newDocument); + }); + + document.addEventListener('astro:page-load', async (e) => { + await runInjectionScript({ + ...${JSON.stringify(internalParams)}, + routerPush: navigate, + routerReplace: (url) => navigate(url, { history: 'replace' }), + }); + }) + } else { + await runInjectionScript(${JSON.stringify(internalParams)}); + }`, ); }, }, diff --git a/packages/astro/src/integration/vite-plugin-astro-config.ts b/packages/astro/src/integration/vite-plugin-astro-config.ts index dfc60db1209..f7f511a4d4c 100644 --- a/packages/astro/src/integration/vite-plugin-astro-config.ts +++ b/packages/astro/src/integration/vite-plugin-astro-config.ts @@ -26,6 +26,8 @@ export function vitePluginAstroConfig(astroConfig: AstroConfig): VitePlugin { // This ensures @clerk/astro/client is properly processed and bundled, // resolving runtime import issues in these components. config.optimizeDeps?.include?.push('@clerk/astro/client'); + // Let astro vite plugin handle this. + config.optimizeDeps?.exclude?.push('astro:transitions/client'); }, load(id) { if (id === resolvedVirtualModuleId) { diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index da6e4b11dcd..597519dda58 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -45,9 +45,9 @@ async function createClerkInstanceInternal(options?: AstroClerkCreateInstancePar } initOptions = { - ...options, routerPush: createNavigationHandler(window.history.pushState.bind(window.history)), routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)), + ...options, }; return clerkJSInstance diff --git a/packages/astro/src/internal/index.ts b/packages/astro/src/internal/index.ts index c00256cf3f8..24c1b6670a9 100644 --- a/packages/astro/src/internal/index.ts +++ b/packages/astro/src/internal/index.ts @@ -13,3 +13,4 @@ const runInjectionScript = createInjectionScriptRunner(createClerkInstance); export { runInjectionScript }; export { generateSafeId } from './utils/generateSafeId'; +export { swapDocument } from './swap-document'; diff --git a/packages/astro/src/internal/swap-document.ts b/packages/astro/src/internal/swap-document.ts new file mode 100644 index 00000000000..0bf08713baa --- /dev/null +++ b/packages/astro/src/internal/swap-document.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line import/no-unresolved +import { swapFunctions } from 'astro:transitions/client'; + +const PERSIST_ATTR = 'data-astro-transition-persist'; +const EMOTION_ATTR = 'data-emotion'; + +/** + * @internal + * Custom swap function to make mounting and styling + * of Clerk components work with View Transitions in Astro. + * + * See https://docs.astro.build/en/guides/view-transitions/#building-a-custom-swap-function + */ +export function swapDocument(doc: Document) { + swapFunctions.deselectScripts(doc); + swapFunctions.swapRootAttributes(doc); + + // Keep the elements created by `@emotion/cache` + const emotionElements = document.querySelectorAll(`style[${EMOTION_ATTR}]`); + swapHeadElements(doc, Array.from(emotionElements)); + + const restoreFocusFunction = swapFunctions.saveFocus(); + swapFunctions.swapBodyElement(doc.body, document.body); + restoreFocusFunction(); +} + +/** + * This function is a copy of the original `swapHeadElements` function from `astro:transitions/client`. + * The difference is that you can pass a list of elements that should not be removed + * in the new document. + * + * See https://github.com/withastro/astro/blob/d6f17044d3873df77cfbc73230cb3194b5a7d82a/packages/astro/src/transitions/swap-functions.ts#L51 + */ +function swapHeadElements(doc: Document, ignoredElements: Element[]) { + for (const el of Array.from(document.head.children)) { + const newEl = persistedHeadElement(el, doc); + + if (newEl) { + newEl.remove(); + } else { + if (!ignoredElements.includes(el)) { + el.remove(); + } + } + } + + document.head.append(...doc.head.children); +} + +function persistedHeadElement(el: Element, newDoc: Document) { + const id = el.getAttribute(PERSIST_ATTR); + const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); + if (newEl) { + return newEl; + } + if (el.matches('link[rel=stylesheet]')) { + const href = el.getAttribute('href'); + return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`); + } + return null; +} diff --git a/packages/astro/tsup.config.ts b/packages/astro/tsup.config.ts index 608373d9937..e453f9b1a11 100644 --- a/packages/astro/tsup.config.ts +++ b/packages/astro/tsup.config.ts @@ -24,6 +24,6 @@ export default defineConfig(() => { bundle: true, sourcemap: true, format: ['esm'], - external: ['astro', 'react', 'react-dom', 'node:async_hooks', '#async-local-storage'], + external: ['astro', 'react', 'react-dom', 'node:async_hooks', '#async-local-storage', 'astro:transitions/client'], }; });