-
Notifications
You must be signed in to change notification settings - Fork 385
feat(react-router): Introduce middleware and context #6660
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
base: main
Are you sure you want to change the base?
Changes from all commits
55d3572
ca486bc
464cb71
7e07dca
b8f9998
328d373
73577de
be2d0b0
6ed6edd
db4ca90
5df2647
5fd6ef8
07406f4
7308966
b356b0f
d8aa016
6d105a1
e95ad51
ff004d0
982f928
e049ba8
14450a3
3d5871e
dc715bd
5f79582
ea60b36
6e92ca9
041c9d4
cab78e7
ae32a9a
37a6dbd
09f675b
7d1c5bc
ca25518
74f4f36
d718198
0975e9d
d57eb20
e5a5194
d94a79c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
--- | ||
'@clerk/react-router': major | ||
--- | ||
|
||
Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities. | ||
|
||
Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now deprecated and will be removed in the next major version. | ||
|
||
**Before (Deprecated - will be removed):** | ||
|
||
```tsx | ||
import { rootAuthLoader } from '@clerk/react-router/ssr.server' | ||
|
||
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) | ||
``` | ||
|
||
**After (Recommended):** | ||
|
||
1. Enable the `v8_middleware` future flag: | ||
|
||
```ts | ||
// react-router.config.ts | ||
export default { | ||
future: { | ||
v8_middleware: true, | ||
}, | ||
} satisfies Config; | ||
``` | ||
|
||
2. Use the middleware in your app: | ||
|
||
```tsx | ||
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' | ||
|
||
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] | ||
|
||
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) | ||
Comment on lines
+35
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this always need to be in Regarding naming, I think we should stick with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Actually,
But in our docs, we require to put the
Great point! Since it's not actually root-specific, But we really also want to recommend putting it in the |
||
``` | ||
Comment on lines
+30
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainVerify stable API names: These names depend on the exact React Router version. Your PR text says “stable APIs” on >=7.9.0; if consumers are below that, they may still need 🌐 Web query:
💡 Result: Short answers:
Sources:
Use documented unstable middleware names (fix snippet + PR text) File: .changeset/quiet-bats-protect.md (lines 30–38)
🤖 Prompt for AI Agents
|
||
|
||
**Streaming Support (with middleware):** | ||
|
||
```tsx | ||
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] | ||
|
||
export const loader = (args: Route.LoaderArgs) => { | ||
const nonCriticalData = new Promise((res) => | ||
setTimeout(() => res('non-critical'), 5000), | ||
) | ||
|
||
return rootAuthLoader(args, () => ({ | ||
nonCriticalData | ||
})) | ||
} | ||
``` |
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -9,21 +9,20 @@ | |||||||||||||||
"typecheck": "react-router typegen && tsc --build --noEmit" | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The template Apply: "scripts": {
"build": "react-router build",
"dev": "react-router dev --port $PORT",
"start": "react-router-serve ./build/server/index.js",
- "typecheck": "react-router typegen && tsc --build --noEmit"
+ "typecheck": "react-router typegen && tsc --noEmit"
}, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||
}, | ||||||||||||||||
"dependencies": { | ||||||||||||||||
"@clerk/react-router": "latest", | ||||||||||||||||
"@react-router/node": "^7.1.2", | ||||||||||||||||
"@react-router/serve": "^7.1.2", | ||||||||||||||||
"@react-router/node": "^7.9.1", | ||||||||||||||||
"@react-router/serve": "^7.9.1", | ||||||||||||||||
"isbot": "^5.1.17", | ||||||||||||||||
"react": "^18.3.1", | ||||||||||||||||
"react-dom": "^18.3.1", | ||||||||||||||||
"react-router": "^7.1.2" | ||||||||||||||||
"react": "^19.1.0", | ||||||||||||||||
"react-dom": "^19.1.0", | ||||||||||||||||
"react-router": "^7.9.1" | ||||||||||||||||
}, | ||||||||||||||||
"devDependencies": { | ||||||||||||||||
"@react-router/dev": "^7.1.2", | ||||||||||||||||
"@react-router/dev": "^7.9.1", | ||||||||||||||||
"@types/node": "^20", | ||||||||||||||||
"@types/react": "^18.3.12", | ||||||||||||||||
"@types/react-dom": "^18.3.1", | ||||||||||||||||
"@types/react": "^19.1.2", | ||||||||||||||||
"@types/react-dom": "^19.1.2", | ||||||||||||||||
"typescript": "^5.7.3", | ||||||||||||||||
"vite": "^5.4.11", | ||||||||||||||||
"vite-tsconfig-paths": "^5.1.2" | ||||||||||||||||
"vite": "^7.1.5", | ||||||||||||||||
"vite-tsconfig-paths": "^5.1.4" | ||||||||||||||||
} | ||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
import { expect, test } from '@playwright/test'; | ||
|
||
import type { Application } from '../../models/application'; | ||
import { appConfigs } from '../../presets'; | ||
import type { FakeUser } from '../../testUtils'; | ||
import { createTestUtils } from '../../testUtils'; | ||
|
||
test.describe('basic tests for @react-router without middleware', () => { | ||
test.describe.configure({ mode: 'parallel' }); | ||
let app: Application; | ||
let fakeUser: FakeUser; | ||
|
||
test.beforeAll(async () => { | ||
test.setTimeout(90_000); // Wait for app to be ready | ||
app = await appConfigs.reactRouter.reactRouterNode | ||
.clone() | ||
.addFile( | ||
`app/root.tsx`, | ||
() => `import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; | ||
import { rootAuthLoader } from '@clerk/react-router/ssr.server'; | ||
import { ClerkProvider } from '@clerk/react-router'; | ||
|
||
import type { Route } from './+types/root'; | ||
|
||
export async function loader(args: Route.LoaderArgs) { | ||
return rootAuthLoader(args); | ||
} | ||
|
||
export function Layout({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<html lang='en'> | ||
<head> | ||
<meta charSet='utf-8' /> | ||
<meta | ||
name='viewport' | ||
content='width=device-width, initial-scale=1' | ||
/> | ||
<Meta /> | ||
<Links /> | ||
</head> | ||
<body> | ||
{children} | ||
<ScrollRestoration /> | ||
<Scripts /> | ||
</body> | ||
</html> | ||
); | ||
} | ||
|
||
export default function App({ loaderData }: Route.ComponentProps) { | ||
return ( | ||
<ClerkProvider loaderData={loaderData}> | ||
<main> | ||
<Outlet /> | ||
</main> | ||
</ClerkProvider> | ||
); | ||
} | ||
|
||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { | ||
let message = 'Oops!'; | ||
let details = 'An unexpected error occurred.'; | ||
let stack: string | undefined; | ||
|
||
if (isRouteErrorResponse(error)) { | ||
message = error.status === 404 ? '404' : 'Error'; | ||
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; | ||
} else if (import.meta.env.DEV && error && error instanceof Error) { | ||
details = error.message; | ||
stack = error.stack; | ||
} | ||
|
||
return ( | ||
<main> | ||
<h1>{message}</h1> | ||
<p>{details}</p> | ||
{stack && ( | ||
<pre> | ||
<code>{stack}</code> | ||
</pre> | ||
)} | ||
</main> | ||
); | ||
} | ||
`, | ||
) | ||
.commit(); | ||
|
||
await app.setup(); | ||
await app.withEnv(appConfigs.envs.withEmailCodes); | ||
await app.dev(); | ||
|
||
const u = createTestUtils({ app }); | ||
fakeUser = u.services.users.createFakeUser({ | ||
fictionalEmail: true, | ||
withPhoneNumber: true, | ||
withUsername: true, | ||
}); | ||
await u.services.users.createBapiUser(fakeUser); | ||
}); | ||
|
||
test.afterAll(async () => { | ||
await fakeUser.deleteIfExists(); | ||
await app.teardown(); | ||
}); | ||
|
||
test.afterEach(async ({ page, context }) => { | ||
const u = createTestUtils({ app, page, context }); | ||
await u.page.signOut(); | ||
await u.page.context().clearCookies(); | ||
}); | ||
|
||
test('can sign in and user button renders', async ({ page, context }) => { | ||
const u = createTestUtils({ app, page, context }); | ||
await u.po.signIn.goTo(); | ||
|
||
await u.po.signIn.setIdentifier(fakeUser.email); | ||
await u.po.signIn.setPassword(fakeUser.password); | ||
await u.po.signIn.continue(); | ||
await u.po.expect.toBeSignedIn(); | ||
|
||
await u.page.waitForAppUrl('/'); | ||
|
||
await u.po.userButton.waitForMounted(); | ||
await u.po.userButton.toggleTrigger(); | ||
await u.po.userButton.waitForPopover(); | ||
|
||
await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]); | ||
}); | ||
|
||
test('redirects to sign-in when unauthenticated', async ({ page, context }) => { | ||
const u = createTestUtils({ app, page, context }); | ||
|
||
await u.page.goToRelative('/protected'); | ||
await u.page.waitForURL(`${app.serverUrl}/sign-in`); | ||
await u.po.signIn.waitForMounted(); | ||
}); | ||
|
||
test('renders control components contents', async ({ page, context }) => { | ||
const u = createTestUtils({ app, page, context }); | ||
|
||
await u.page.goToAppHome(); | ||
await expect(u.page.getByText('SignedOut')).toBeVisible(); | ||
|
||
await u.page.goToRelative('/sign-in'); | ||
await u.po.signIn.waitForMounted(); | ||
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); | ||
await u.po.expect.toBeSignedIn(); | ||
await expect(u.page.getByText('SignedIn')).toBeVisible(); | ||
}); | ||
|
||
test('renders user profile with SSR data', async ({ page, context }) => { | ||
const u = createTestUtils({ app, page, context }); | ||
|
||
await u.page.goToRelative('/sign-in'); | ||
await u.po.signIn.waitForMounted(); | ||
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); | ||
await u.po.expect.toBeSignedIn(); | ||
|
||
await u.po.userButton.waitForMounted(); | ||
await u.page.goToRelative('/protected'); | ||
await u.po.userProfile.waitForMounted(); | ||
|
||
// Fetched from an API endpoint (/api/me), which is server-rendered. | ||
// This also verifies that the server middleware is working. | ||
await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible(); | ||
await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); | ||
}); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.