diff --git a/demos/spa/README.md b/demos/spa/README.md new file mode 100644 index 00000000000..80d913f9dc7 --- /dev/null +++ b/demos/spa/README.md @@ -0,0 +1,29 @@ +# SPA Demo + +A small Vite app that uses Remix as a client-only router. It demonstrates a `URL -> RemixNode` contract configured through `RouterTypes.output` and rendered by the `SPA` component from `remix/ui/spa`. + +Navigation uses `router.fetch(url, { signal })`. The router turns the URL into an internal `Request`, so the same signal is available to handlers as `context.request.signal` and superseded page loads are cancelled. + +POST form submissions are intercepted through the Navigation API. The listener forwards the event's `FormData` to `router.fetch(url, { method: 'POST', body, signal })`, where handlers can read it with `context.request.formData()`. + +Navigation history entries do not retain submitted `FormData`. Back and forward navigations to a form destination therefore arrive as GET requests, so form destinations must accept both GET and POST. This demo declares `/greet` without a method restriction and only reads `request.formData()` for POST requests. + +A submission to a new URL pushes a history entry. A submission to the active URL replaces the current entry using `NavigationPrecommitController` when available, with a programmatic replacement navigation as a fallback. + +## Run It + +```sh +pnpm -C demos/spa dev +``` + +Then open `http://localhost:44100`. + +## Test It + +The end-to-end tests build and preview the production app by default, then pass Vite's URL and +cleanup method directly to `t.serve()`. Call `runTests('development')` in the test file to run the +same suite against Vite's development server instead. + +```sh +pnpm -C demos/spa test +``` diff --git a/demos/spa/app/actions/controller.tsx b/demos/spa/app/actions/controller.tsx new file mode 100644 index 00000000000..0cba1742ecd --- /dev/null +++ b/demos/spa/app/actions/controller.tsx @@ -0,0 +1,202 @@ +import { createController } from 'remix/router' +import { css, type Handle } from 'remix/ui' + +import { routes } from '../routes.ts' + +export default createController(routes, { + actions: { + async home(context) { + await sleep(1000, context.request.signal) + return + }, + + async about(context) { + await sleep(1000, context.request.signal) + return + }, + + async greet(context) { + let isSubmission = context.request.method === 'POST' + let name = 'friend' + if (isSubmission) { + let formData = await context.request.formData() + let value = formData.get('name') + if (typeof value === 'string' && value.trim() !== '') { + name = value.trim() + } + } + + await sleep(1000, context.request.signal) + return + }, + }, +}) + +function HomePage() { + return () => ( +
+

Home

+

A client-only Remix app

+

+ This page came directly from a fetch router handler. No HTTP request or response was + involved. +

+
+ +
+ + +
+
+
+ ) +} + +function AboutPage() { + return () => ( +
+

About

+

URLs in, rendered UI out

+

+ Each route waits briefly before returning a RemixNode, so the loading and + cancellation behavior is easy to see. +

+
+ ) +} + +function GreetingPage(handle: Handle<{ isSubmission: boolean; name: string }>) { + return () => ( +
+ {handle.props.isSubmission ?

Form submitted

: null} +

Hello, {handle.props.name}!

+

+ POST submissions expose the Navigation API's form data through{' '} + context.request.formData() without making an HTTP request. History traversals + return here with GET because navigation entries do not retain FormData. +

+
+ +
+ + +
+
+

+ Because this form submits to the current URL, it replaces the current history entry. The{' '} + + first submission + {' '} + pushed a new entry because it navigated here from another URL. +

+
+ ) +} + +export function NotFoundPage() { + return () => ( +
+

404

+

Page not found

+

+ Try going back to the{' '} + + home page + + . +

+
+ ) +} + +function sleep(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + + let timeout = setTimeout(() => { + signal.removeEventListener('abort', handleAbort) + resolve() + }, milliseconds) + + function handleAbort() { + clearTimeout(timeout) + reject(signal.reason) + } + + signal.addEventListener('abort', handleAbort, { once: true }) + }) +} + +const eyebrowStyle = css({ + margin: '0 0 0.5rem', + color: '#6a48d7', + fontSize: '0.75rem', + fontWeight: 700, + letterSpacing: '0.12em', + textTransform: 'uppercase', +}) + +const titleStyle = css({ + margin: 0, + fontSize: 'clamp(2rem, 7vw, 3.5rem)', + lineHeight: 1.05, +}) + +const bodyStyle = css({ + maxWidth: '38rem', + margin: '1.5rem 0 0', + color: '#5c5965', + fontSize: '1.125rem', + lineHeight: 1.7, +}) + +const formStyle = css({ + display: 'grid', + gap: '0.75rem', + maxWidth: '30rem', + marginTop: '2rem', +}) + +const labelStyle = css({ + fontWeight: 700, +}) + +const formControlsStyle = css({ + display: 'flex', + gap: '0.75rem', +}) + +const inputStyle = css({ + minWidth: 0, + flex: 1, + border: '1px solid #bcb4d4', + borderRadius: '0.6rem', + padding: '0.7rem 0.8rem', + font: 'inherit', +}) + +const buttonStyle = css({ + border: 0, + borderRadius: '0.6rem', + padding: '0.7rem 1rem', + color: 'white', + backgroundColor: '#5b36d6', + font: 'inherit', + fontWeight: 700, + cursor: 'pointer', +}) + +const linkStyle = css({ + color: '#5b36d6', +}) diff --git a/demos/spa/app/app.test.e2e.ts b/demos/spa/app/app.test.e2e.ts new file mode 100644 index 00000000000..5344d9f3353 --- /dev/null +++ b/demos/spa/app/app.test.e2e.ts @@ -0,0 +1,95 @@ +import { fileURLToPath } from 'node:url' +import * as assert from 'remix/assert' +import { beforeAll, describe, it } from 'remix/test' +import { build, createServer, preview, type ViteDevServer, type PreviewServer } from 'vite' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const mode: 'development' | 'production' = 'production' + +async function createViteTestServer() { + let opts: Parameters[0] | Parameters[0] = { + root, + logLevel: 'silent', + server: { + host: '127.0.0.1', + port: 0, + }, + } + + let vite: ViteDevServer | PreviewServer + if (mode === 'development') { + vite = await createServer(opts) + await vite.listen() + } else { + vite = await preview(opts) + } + + let address = vite.httpServer?.address() + if (address == null || typeof address === 'string') { + await vite.close() + throw new Error('Vite did not bind to a TCP port') + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + async close() { + await vite.close() + }, + } +} + +describe(`SPA (${mode})`, () => { + beforeAll(async () => { + if (mode === 'production') { + await build({ root, logLevel: 'silent' }) + } + }) + + it('loads a client route directly through the Vite history fallback', async (t) => { + let page = await t.serve(await createViteTestServer()) + + await page.goto('/about') + await page.getByRole('status').waitFor() + await page.getByRole('heading', { name: 'URLs in, rendered UI out' }).waitFor() + + assert.equal(new URL(page.url()).pathname, '/about') + assert.equal( + await page.getByRole('link', { name: 'About' }).getAttribute('aria-current'), + 'page', + ) + }) + + it('navigates between routes without loading a new document', async (t) => { + let page = await t.serve(await createViteTestServer()) + + await page.goto('/') + await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() + + let navigation = page.getByRole('link', { name: 'About' }).click() + await page.getByRole('status').waitFor() + assert.equal(new URL(page.url()).pathname, '/about') + + await navigation + await page.getByRole('heading', { name: 'URLs in, rendered UI out' }).waitFor() + }) + + it('pushes new form destinations and replaces submissions to the active URL', async (t) => { + let page = await t.serve(await createViteTestServer()) + + await page.goto('/') + await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() + + await page.getByLabel('What should we call you?').fill('Ada') + await page.getByRole('button', { name: 'Submit' }).click() + await page.getByRole('heading', { name: 'Hello, Ada!' }).waitFor() + assert.equal(new URL(page.url()).pathname, '/greet') + + await page.getByLabel('Try another name').fill('Grace') + await page.getByRole('button', { name: 'Submit again' }).click() + await page.getByRole('heading', { name: 'Hello, Grace!' }).waitFor() + + await page.goBack() + await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() + assert.equal(new URL(page.url()).pathname, '/') + }) +}) diff --git a/demos/spa/app/main.tsx b/demos/spa/app/main.tsx new file mode 100644 index 00000000000..8bd6575b95e --- /dev/null +++ b/demos/spa/app/main.tsx @@ -0,0 +1,13 @@ +import { createRoot } from 'remix/ui' +import { SPA } from 'remix/ui/spa' + +import { router } from './router.tsx' +import { Fallback } from './ui/layout.tsx' + +const root = createRoot(document.getElementById('app')!) + +root.addEventListener('error', (event) => { + console.error('Remix UI root failed:', event.error) +}) + +root.render(} />) diff --git a/demos/spa/app/middleware/render.tsx b/demos/spa/app/middleware/render.tsx new file mode 100644 index 00000000000..912c3f248fd --- /dev/null +++ b/demos/spa/app/middleware/render.tsx @@ -0,0 +1,10 @@ +import type { Middleware } from 'remix/router' + +import { Layout } from '../ui/layout.tsx' + +export function render(): Middleware { + return async (_context, next) => { + let node = await next() + return {node} + } +} diff --git a/demos/spa/app/router.tsx b/demos/spa/app/router.tsx new file mode 100644 index 00000000000..96ef6272cf5 --- /dev/null +++ b/demos/spa/app/router.tsx @@ -0,0 +1,19 @@ +import { createRouter } from 'remix/router' +import type { RemixNode } from 'remix/ui' + +import rootController, { NotFoundPage } from './actions/controller.tsx' +import { render } from './middleware/render.tsx' +import { routes } from './routes.ts' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +export const router = createRouter({ + middleware: [render()], + defaultHandler: () => , +}) + +router.map(routes, rootController) diff --git a/demos/spa/app/routes.ts b/demos/spa/app/routes.ts new file mode 100644 index 00000000000..508f7a9910a --- /dev/null +++ b/demos/spa/app/routes.ts @@ -0,0 +1,7 @@ +import { get, route } from 'remix/routes' + +export const routes = route({ + home: get('/'), + about: get('/about'), + greet: '/greet', +}) diff --git a/demos/spa/app/ui/layout.tsx b/demos/spa/app/ui/layout.tsx new file mode 100644 index 00000000000..1f7aff5308d --- /dev/null +++ b/demos/spa/app/ui/layout.tsx @@ -0,0 +1,133 @@ +import { css, type Handle, type RemixNode } from 'remix/ui' +import { SPA } from 'remix/ui/spa' + +import { routes } from '../routes.ts' + +interface LayoutProps { + children?: RemixNode +} + +export function Fallback() { + return () => ( + + + + ) +} + +export function Layout(handle: Handle) { + let router = handle.context.get(SPA) + + return () => { + let isPending = router.pending != null + let content = isPending ? : handle.props.children + + return ( +
+ +
+ ) + } +} + +export function LoadingPage() { + return () => ( +
+ Loading… +
+ ) +} + +const appShellStyle = css({ + position: 'fixed', + inset: 0, + minWidth: 320, + overflow: 'auto', + color: '#202124', + backgroundColor: '#f7f5ff', + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + fontSynthesis: 'none', + '& *': { + boxSizing: 'border-box', + }, +}) + +const contentStyle = css({ + width: 'min(100% - 2rem, 48rem)', + margin: '0 auto', +}) + +const headerStyle = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '1.5rem 0', +}) + +const brandStyle = css({ + color: 'inherit', + fontSize: '1.125rem', + fontWeight: 700, + textDecoration: 'none', +}) + +const navStyle = css({ + display: 'flex', + gap: '0.5rem', +}) + +const navLinkStyle = css({ + borderRadius: 999, + padding: '0.5rem 0.75rem', + color: '#5b36d6', + textDecoration: 'none', + '&:hover, &[aria-current="page"]': { + backgroundColor: '#e7e0ff', + }, +}) + +const mainStyle = css({ + minHeight: '18rem', + border: '1px solid #ded8ef', + borderRadius: '1rem', + backgroundColor: 'white', + boxShadow: '0 1rem 3rem rgb(64 44 120 / 10%)', + padding: 'clamp(2rem, 8vw, 5rem)', +}) + +const loadingStyle = css({ + color: '#6a48d7', + fontSize: '1.125rem', +}) diff --git a/demos/spa/index.html b/demos/spa/index.html new file mode 100644 index 00000000000..734cafbf1a8 --- /dev/null +++ b/demos/spa/index.html @@ -0,0 +1,14 @@ + + + + + + + + Remix SPA Demo + + +
+ + + diff --git a/demos/spa/package.json b/demos/spa/package.json new file mode 100644 index 00000000000..00616005aee --- /dev/null +++ b/demos/spa/package.json @@ -0,0 +1,25 @@ +{ + "name": "spa-demo", + "private": true, + "type": "module", + "engines": { + "node": ">=24.3.0" + }, + "dependencies": { + "remix": "workspace:*" + }, + "devDependencies": { + "@types/dom-navigation": "^1.0.7", + "@types/node": "catalog:", + "playwright": "catalog:", + "typescript": "catalog:", + "vite": "7.3.3" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "remix test", + "typecheck": "tsc --noEmit" + } +} diff --git a/demos/spa/tsconfig.json b/demos/spa/tsconfig.json new file mode 100644 index 00000000000..fedf5a089d1 --- /dev/null +++ b/demos/spa/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "remix/ui", + "types": ["vite/client"] + }, + "include": ["app", "vite.config.ts"] +} diff --git a/demos/spa/vite.config.ts b/demos/spa/vite.config.ts new file mode 100644 index 00000000000..128d2c80d31 --- /dev/null +++ b/demos/spa/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 44100, + }, + preview: { + port: 44100, + }, +}) diff --git a/packages/async-context-middleware/.changes/patch.custom-router-outputs.md b/packages/async-context-middleware/.changes/patch.custom-router-outputs.md new file mode 100644 index 00000000000..ad5b89cb805 --- /dev/null +++ b/packages/async-context-middleware/.changes/patch.custom-router-outputs.md @@ -0,0 +1 @@ +Preserve custom router output types in `asyncContext()` and `getContext()`. diff --git a/packages/async-context-middleware/src/lib/async-context.ts b/packages/async-context-middleware/src/lib/async-context.ts index 4799b0745ee..236efecd6cf 100644 --- a/packages/async-context-middleware/src/lib/async-context.ts +++ b/packages/async-context-middleware/src/lib/async-context.ts @@ -10,17 +10,17 @@ import type { } from '@remix-run/fetch-router' type RequestContextWithAnyParams = - context extends RequestContext - ? ContextWithEntries, entries> + context extends RequestContext + ? ContextWithEntries, entries> : RequestContext export type AsyncRequestContext = RouterTypes extends { - context: infer context extends RequestContext + context: infer context extends RequestContext } ? RequestContextWithAnyParams : RequestContext -const storage = new AsyncLocalStorage>() +const storage = new AsyncLocalStorage>() /** * Middleware that stores the request context in `AsyncLocalStorage` so it is available diff --git a/packages/auth-middleware/.changes/patch.response-output.md b/packages/auth-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..3ea26ede847 --- /dev/null +++ b/packages/auth-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Keep `auth()` compatible with custom router outputs while declaring `requireAuth()` as Response-only middleware. `requireAuth()` now throws a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/auth-middleware/src/lib/auth.ts b/packages/auth-middleware/src/lib/auth.ts index 71f4dea2f0d..f90490d6311 100644 --- a/packages/auth-middleware/src/lib/auth.ts +++ b/packages/auth-middleware/src/lib/auth.ts @@ -95,7 +95,7 @@ export interface AuthScheme { name: string /** Authenticates the current request or returns `null`/`undefined` to skip the scheme. */ authenticate( - context: RequestContext, + context: RequestContext, ): AuthSchemeAuthenticateResult | Promise> } @@ -166,7 +166,7 @@ export function auth[]>( } } -function setAuthState(context: RequestContext, auth: AuthState): void { +function setAuthState(context: RequestContext, auth: AuthState): void { context.set(Auth, auth, { property: 'auth' }) } diff --git a/packages/auth-middleware/src/lib/require-auth.test.ts b/packages/auth-middleware/src/lib/require-auth.test.ts index d1129743952..8c5547776e5 100644 --- a/packages/auth-middleware/src/lib/require-auth.test.ts +++ b/packages/auth-middleware/src/lib/require-auth.test.ts @@ -222,4 +222,28 @@ describe('requireAuth middleware', () => { await router.fetch('https://remix.run/') }, new Error('Auth state not found. Make sure auth() middleware runs before requireAuth().')) }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ + middleware: [ + auth({ + schemes: [ + { + name: 'test', + authenticate: () => ({ status: 'success', identity: null }), + }, + ], + }), + requireAuth(), + ], + }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('requireAuth() expected next() to return a Response'), + ) + }) }) diff --git a/packages/auth-middleware/src/lib/require-auth.ts b/packages/auth-middleware/src/lib/require-auth.ts index 9e57f21819b..e9a2721551f 100644 --- a/packages/auth-middleware/src/lib/require-auth.ts +++ b/packages/auth-middleware/src/lib/require-auth.ts @@ -19,7 +19,7 @@ export interface RequireAuthOptions { */ export function requireAuth( options: RequireAuthOptions = {}, -): Middleware<{ key: typeof Auth; value: GoodAuth; property: 'auth' }> { +): Middleware<{ key: typeof Auth; value: GoodAuth; property: 'auth' }, Response> { return async (context, next) => { let auth = context.get(Auth) if (auth == null) { @@ -30,7 +30,9 @@ export function requireAuth( if (auth.ok) { context.set(Auth, auth, { property: 'auth' }) - return next() + let response = await next() + assertResponse(response) + return response } let response = await createFailureResponse(auth, context, options) @@ -44,6 +46,12 @@ export function requireAuth( } } +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('requireAuth() expected next() to return a Response') + } +} + async function createFailureResponse( auth: BadAuth, context: RequestContext, diff --git a/packages/compression-middleware/.changes/patch.response-output.md b/packages/compression-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..6dddb196c32 --- /dev/null +++ b/packages/compression-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `compression()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/compression-middleware/src/lib/compression.test.ts b/packages/compression-middleware/src/lib/compression.test.ts index c48ac14de78..0c30bfb24a4 100644 --- a/packages/compression-middleware/src/lib/compression.test.ts +++ b/packages/compression-middleware/src/lib/compression.test.ts @@ -293,4 +293,16 @@ describe('compression()', () => { }) assert.equal(compressResponse.headers.get('Content-Encoding'), 'gzip') }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ middleware: [compression()] }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('compression() expected next() to return a Response'), + ) + }) }) diff --git a/packages/compression-middleware/src/lib/compression.ts b/packages/compression-middleware/src/lib/compression.ts index c316fa772f6..1cc0fc32a99 100644 --- a/packages/compression-middleware/src/lib/compression.ts +++ b/packages/compression-middleware/src/lib/compression.ts @@ -61,9 +61,10 @@ export interface CompressionOptions { * }) * ``` */ -export function compression(options?: CompressionOptions): Middleware { +export function compression(options?: CompressionOptions): Middleware { return async (context, next) => { let response = await next() + assertResponse(response) let contentTypeHeader = response.headers.get('Content-Type') if (!contentTypeHeader) { @@ -102,3 +103,9 @@ export function compression(options?: CompressionOptions): Middleware { return compressResponse(response, context.request, compressOptions) } } + +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('compression() expected next() to return a Response') + } +} diff --git a/packages/cop-middleware/.changes/patch.response-output.md b/packages/cop-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..84952bc0570 --- /dev/null +++ b/packages/cop-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `cop()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/cop-middleware/src/lib/cop.test.ts b/packages/cop-middleware/src/lib/cop.test.ts index 7bcfcbe2d63..78d08bbffd9 100644 --- a/packages/cop-middleware/src/lib/cop.test.ts +++ b/packages/cop-middleware/src/lib/cop.test.ts @@ -292,4 +292,16 @@ describe('cop middleware', () => { assert.throws(() => cop({ insecureBypassPatterns: ['POST foo'] })) assert.throws(() => cop({ insecureBypassPatterns: ['/foo/{...}/bar'] })) }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ middleware: [cop()] }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('cop() expected next() to return a Response'), + ) + }) }) diff --git a/packages/cop-middleware/src/lib/cop.ts b/packages/cop-middleware/src/lib/cop.ts index 4d3c47dd13e..370c99193b1 100644 --- a/packages/cop-middleware/src/lib/cop.ts +++ b/packages/cop-middleware/src/lib/cop.ts @@ -140,19 +140,27 @@ function isSafeMethod(method: string): boolean { * @param options Cross-origin protection options. * @returns Middleware that validates request origin headers. */ -export function cop(options: CopOptions = {}): Middleware { +export function cop(options: CopOptions = {}): Middleware { let protection = new CrossOriginProtection(options) return async (context, next) => { let reason = protection.check(context) if (reason == null) { - return next() + let response = await next() + assertResponse(response) + return response } return protection.deny(reason, context) } } +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('cop() expected next() to return a Response') + } +} + function getDefaultErrorMessage(reason: CopFailureReason): string { if (reason === 'cross-origin-request') { return 'Forbidden: cross-origin request detected from Sec-Fetch-Site header' diff --git a/packages/cors-middleware/.changes/patch.response-output.md b/packages/cors-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..fdde3ba2c33 --- /dev/null +++ b/packages/cors-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `cors()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/cors-middleware/src/lib/cors.test.ts b/packages/cors-middleware/src/lib/cors.test.ts index 2cf2a7ac459..6408f58cc18 100644 --- a/packages/cors-middleware/src/lib/cors.test.ts +++ b/packages/cors-middleware/src/lib/cors.test.ts @@ -324,4 +324,16 @@ describe('cors middleware', () => { assert.ok(vary.has('Accept-Encoding')) assert.ok(vary.has('Origin')) }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ middleware: [cors()] }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('cors() expected next() to return a Response'), + ) + }) }) diff --git a/packages/cors-middleware/src/lib/cors.ts b/packages/cors-middleware/src/lib/cors.ts index d2c4f8ca7c9..4a74f99b004 100644 --- a/packages/cors-middleware/src/lib/cors.ts +++ b/packages/cors-middleware/src/lib/cors.ts @@ -125,7 +125,7 @@ type ResolvedAllowedHeaders = { * @param options CORS options * @returns CORS middleware */ -export function cors(options: CorsOptions = {}): Middleware { +export function cors(options: CorsOptions = {}): Middleware { let methods = normalizeMethodList(options.methods ?? defaultCorsMethods) let exposedHeaders = options.exposedHeaders ? normalizeHeaderList(options.exposedHeaders) : '' let allowCredentials = options.credentials ?? false @@ -141,7 +141,9 @@ export function cors(options: CorsOptions = {}): Middleware { return new Response(null, { status: preflightStatusCode }) } - return next() + let response = await next() + assertResponse(response) + return response } let allowedOrigin = await resolveAllowedOrigin(requestOrigin, context, options.origin) @@ -150,7 +152,9 @@ export function cors(options: CorsOptions = {}): Middleware { return new Response(null, { status: 403 }) } - return next() + let response = await next() + assertResponse(response) + return response } let corsHeaders = new Headers() @@ -212,11 +216,18 @@ export function cors(options: CorsOptions = {}): Middleware { } let response = await next() + assertResponse(response) return withCorsHeaders(response, corsHeaders, vary) } } +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('cors() expected next() to return a Response') + } +} + function isPreflightRequest(context: RequestContext): boolean { return context.method === 'OPTIONS' && context.headers.has('Access-Control-Request-Method') } diff --git a/packages/csrf-middleware/.changes/patch.response-output.md b/packages/csrf-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..edc02d47f4b --- /dev/null +++ b/packages/csrf-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `csrf()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/csrf-middleware/src/lib/csrf.test.ts b/packages/csrf-middleware/src/lib/csrf.test.ts index 4bdd3a98745..f56ea788300 100644 --- a/packages/csrf-middleware/src/lib/csrf.test.ts +++ b/packages/csrf-middleware/src/lib/csrf.test.ts @@ -256,4 +256,20 @@ describe('csrf middleware', () => { }) }, new Error('csrf middleware requires session() middleware to run before it')) }) + + it('throws when the next handler does not return a Response', async () => { + let cookie = createCookie('__session', { secrets: ['secret1'] }) + let storage = createCookieSessionStorage() + let router = createRouter({ + middleware: [session(cookie, storage), csrf()], + }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('csrf() expected next() to return a Response'), + ) + }) }) diff --git a/packages/csrf-middleware/src/lib/csrf.ts b/packages/csrf-middleware/src/lib/csrf.ts index 3ae531f2abb..51d49ae987f 100644 --- a/packages/csrf-middleware/src/lib/csrf.ts +++ b/packages/csrf-middleware/src/lib/csrf.ts @@ -122,7 +122,7 @@ export interface CsrfOptions { * @param options CSRF options * @returns CSRF middleware */ -export function csrf(options: CsrfOptions = {}): Middleware { +export function csrf(options: CsrfOptions = {}): Middleware { let safeMethods = options.safeMethods ?? defaultSafeMethods let tokenKey = options.tokenKey ?? '_csrf' let fieldName = options.fieldName ?? '_csrf' @@ -137,7 +137,9 @@ export function csrf(options: CsrfOptions = {}): Middleware { let expectedToken = getCsrfToken(context, tokenKey) if (isSafeMethod(context.method, safeMethods)) { - return next() + let response = await next() + assertResponse(response) + return response } let validOrigin = await validateRequestOrigin( @@ -160,7 +162,15 @@ export function csrf(options: CsrfOptions = {}): Middleware { return getErrorResponse(options, 'invalid-token', context) } - return next() + let response = await next() + assertResponse(response) + return response + } +} + +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('csrf() expected next() to return a Response') } } diff --git a/packages/fetch-router/.changes/minor.generic-router-output.md b/packages/fetch-router/.changes/minor.generic-router-output.md new file mode 100644 index 00000000000..94be1356420 --- /dev/null +++ b/packages/fetch-router/.changes/minor.generic-router-output.md @@ -0,0 +1 @@ +Add an augmentable `RouterTypes.output` contract to `createRouter`. Routers can now return application values such as `RemixNode`, with the output type propagated through middleware, controllers, mounted routes, and nested `context.router.fetch` calls. Middleware that explicitly requires `Response` output is rejected by custom-output routers. Existing `string | URL | Request` inputs and cancellation through `router.fetch(url, { signal })` remain unchanged. diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 4bf3dcf75c3..2d9de2d910d 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -78,6 +78,32 @@ let response = await router.fetch('https://remix.run/blog/hello-remix') console.log(await response.text()) // "Post hello-remix" ``` +### Custom Router Outputs + +Routers accept `string | URL | Request` and return `Response` by default. Apps can augment `RouterTypes.output` when the router provides an in-memory contract instead of an HTTP boundary. For example, a browser-only router can map URLs directly to renderable nodes: + +```tsx +import { createRouter } from 'remix/router' +import type { RemixNode } from 'remix/ui' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +let router = createRouter({ + defaultHandler: () => null, +}) + +router.get('/', () =>

Home

) +router.get('/about', () =>

About

) + +let node = await router.fetch(new URL('/about', location.href)) +``` + +The augmented output becomes the default for routers, middleware, actions, and controllers throughout the app. The router still creates an internal `Request`, so handlers and middleware retain the standard `RequestContext` APIs. The `signal` passed to `router.fetch(url, { signal })` is reflected by `context.request.signal` and cancels router dispatch as usual. + The route map is an object of the same shape as the object pass into `route()`, including nested objects. The leaves of the map are `Route` objects, which you can see if you inspect the type of the `routes` variable in your IDE. ```ts diff --git a/packages/fetch-router/src/index.ts b/packages/fetch-router/src/index.ts index 3b46b2e657f..86e84592771 100644 --- a/packages/fetch-router/src/index.ts +++ b/packages/fetch-router/src/index.ts @@ -30,5 +30,6 @@ export type { RouteInstaller, Router, RouterContext, + RouterOutput, RouterOptions, } from './lib/router.ts' diff --git a/packages/fetch-router/src/lib/controller.ts b/packages/fetch-router/src/lib/controller.ts index eea4092b99d..a4c62256ad2 100644 --- a/packages/fetch-router/src/lib/controller.ts +++ b/packages/fetch-router/src/lib/controller.ts @@ -2,24 +2,28 @@ import type { RoutePattern } from '@remix-run/route-pattern' import type { MatchParams } from '@remix-run/route-pattern/match' import type { AnyMiddleware, MiddlewareContext } from './middleware.ts' -import type { ContextWithParams, RequestContext } from './request-context.ts' +import type { ContextWithOutput, ContextWithParams, RequestContext } from './request-context.ts' import type { Route, RouteMap } from './route-map.ts' -import type { DefaultContext } from './router-types.ts' +import type { DefaultContext, DefaultOutput } from './router-types.ts' +import type { Defined } from './type-utils.ts' /** - * A request handler function that returns some kind of response. + * A request handler function that returns the router's output type. * * @param context The request context - * @returns The response + * @returns The router output */ -export interface RequestHandler = RequestContext> { +export interface RequestHandler< + context extends RequestContext = RequestContext, + output = DefaultOutput, +> { /** - * Handles a matched request and returns the response. + * Handles a matched request and returns the router output. */ - (context: context): Response | Promise + (context: context): Defined | Promise> } -export function isRequestHandler(object: unknown): object is RequestHandler { +export function isRequestHandler(object: unknown): object is RequestHandler { return typeof object === 'function' } @@ -34,22 +38,27 @@ type ActionPattern = type ActionContext< route extends ActionRoute, - context extends RequestContext, -> = ContextWithParams>> + context extends RequestContext, + output, +> = ContextWithParams, MatchParams>> export type ActionObject< route extends ActionRoute, - context extends RequestContext = DefaultContext, - middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + context extends RequestContext = DefaultContext, + middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, > = { /** * Middleware that runs before this action's handler. */ - middleware?: readonly [...middleware] + middleware?: readonly [...middleware] & readonly AnyMiddleware[] /** * The handler that runs after this action's middleware. */ - handler: RequestHandler>> + handler: RequestHandler< + MiddlewareContext>, + output + > } /** @@ -61,9 +70,12 @@ export type ActionObject< */ export type Action< route extends ActionRoute, - context extends RequestContext = DefaultContext, - middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], -> = RequestHandler> | ActionObject + context extends RequestContext = DefaultContext, + middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, +> = + | RequestHandler, output> + | ActionObject /** * Defines a route handler with route-aware params and the default router context. @@ -78,29 +90,36 @@ export type Action< */ export function createAction< route extends ActionRoute, - context extends RequestContext = DefaultContext, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], ->(route: route, action: Action): Action { + context extends RequestContext = DefaultContext, + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, +>( + route: route, + action: Action, +): Action { void route return action } -export function isAction(obj: unknown): obj is Action { +export function isAction(obj: unknown): obj is Action { return isRequestHandler(obj) || isActionObject(obj) } -export function isActionObject(obj: unknown): obj is ActionObject { +export function isActionObject(obj: unknown): obj is ActionObject { return isRecord(obj) && typeof obj.handler === 'function' } type ControllerActions< routes extends RouteMap, - context extends RequestContext, + context extends RequestContext, + output, > = routes extends any ? { [name in keyof routes as routes[name] extends Route ? name - : never]: routes[name] extends Route ? Action : never + : never]: routes[name] extends Route + ? Action[], output> + : never } & { [name in keyof routes as routes[name] extends RouteMap ? name : never]?: never } @@ -116,11 +135,16 @@ type ControllerActions< */ export type Controller< routes extends RouteMap, - context extends RequestContext = DefaultContext, - middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + context extends RequestContext = DefaultContext, + middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, > = { - middleware?: readonly [...middleware] - actions: ControllerActions> + middleware?: readonly [...middleware] & readonly AnyMiddleware[] + actions: ControllerActions< + routes, + MiddlewareContext>, + output + > } /** @@ -136,18 +160,19 @@ export type Controller< */ export function createController< routes extends RouteMap, - context extends RequestContext = DefaultContext, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + context extends RequestContext = DefaultContext, + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, >( routes: routes, - controller: Controller, -): Controller { + controller: Controller, +): Controller { void routes return controller } export function isController(obj: unknown): obj is { - middleware?: readonly AnyMiddleware[] | undefined + middleware?: readonly AnyMiddleware[] | undefined actions: Record } { return isRecord(obj) && isRecord(obj.actions) diff --git a/packages/fetch-router/src/lib/middleware.test.ts b/packages/fetch-router/src/lib/middleware.test.ts index b7cf8e72e96..4fcda8cba2d 100644 --- a/packages/fetch-router/src/lib/middleware.test.ts +++ b/packages/fetch-router/src/lib/middleware.test.ts @@ -105,7 +105,7 @@ describe('runMiddleware', () => { await assert.rejects(async () => { await runMiddleware(middleware, context, handler) - }, new Error('Middleware must return a Response or call next()')) + }, new Error('Middleware must return a value or call next()')) }) it('rejects when a middleware calls next() multiple times', async () => { diff --git a/packages/fetch-router/src/lib/middleware.ts b/packages/fetch-router/src/lib/middleware.ts index 773cacad6ab..939a561f877 100644 --- a/packages/fetch-router/src/lib/middleware.ts +++ b/packages/fetch-router/src/lib/middleware.ts @@ -7,26 +7,30 @@ import type { ContextWithEntry, RequestContext, } from './request-context.ts' +import type { DefaultOutput } from './router-types.ts' +import type { Defined } from './type-utils.ts' /** * A middleware of any context transform. */ -export type AnyMiddleware = Middleware +export type AnyMiddleware = Middleware type ContextTransform = | ContextEntry | ContextEntries - | (>(context: context) => RequestContext) + | (>( + context: context, + ) => RequestContext) type EmptyContextTransform = readonly [] declare const contextTransform: unique symbol type TransformOf = - middleware extends Middleware ? transform : EmptyContextTransform + middleware extends Middleware ? transform : EmptyContextTransform type ContextWithTransform< - context extends RequestContext, + context extends RequestContext, transform, > = transform extends ContextEntries ? ContextWithEntries @@ -35,7 +39,7 @@ type ContextWithTransform< : transform extends { (context: inputContext): infer output } - ? output extends RequestContext + ? output extends RequestContext ? output : context : context @@ -44,33 +48,36 @@ type ContextWithTransform< * Resolves the request-context type produced by a middleware tuple. */ export type MiddlewareContext< - middleware extends readonly AnyMiddleware[], - context extends RequestContext = RequestContext, + middleware extends readonly AnyMiddleware[], + context extends RequestContext = RequestContext, > = number extends middleware['length'] ? context : middleware extends readonly [ - infer first extends AnyMiddleware, - ...infer rest extends readonly AnyMiddleware[], + infer first extends AnyMiddleware, + ...infer rest extends readonly AnyMiddleware[], ] ? MiddlewareContext>> : context /** - * A special kind of request handler that either returns a response or passes control + * A special kind of request handler that either returns a router output or passes control * to the next middleware or request handler in the chain. * * @param context The request context * @param next A function that invokes the next middleware or request handler in the chain - * @returns A response to short-circuit the chain, or the response from `next()` to continue + * @returns An output to short-circuit the chain, or the output from `next()` to continue * * The generic describes the context effect this middleware has. Use a `{ key, value }` object for * middleware that provides one context value, add a `property` field to install a direct context * property, or use {@link ContextEntries} for multiple values. */ -export type Middleware = (( - context: RequestContext, - next: NextFunction, -) => Response | Promise) & { +export type Middleware< + transform extends ContextTransform = EmptyContextTransform, + output = DefaultOutput, +> = (( + context: RequestContext, + next: NextFunction, +) => Defined | Promise>) & { /** * Type-only metadata that carries the middleware's declared context effect. */ @@ -97,18 +104,18 @@ export function createMiddleware Promise +export type NextFunction = () => Promise> -export function runMiddleware( - middleware: AnyMiddleware[], - context: RequestContext, - handler: RequestHandler, -): Promise { +export function runMiddleware( + middleware: AnyMiddleware[], + context: RequestContext, + handler: RequestHandler, +): Promise> { let index = -1 - let dispatch = async (i: number): Promise => { + let dispatch = async (i: number): Promise> => { if (i <= index) throw new Error('next() called multiple times') index = i @@ -121,25 +128,25 @@ export function runMiddleware( return await raceRequestAbort(Promise.resolve(handler(context)), context.request) } - let nextPromise: Promise | undefined - let next: NextFunction = () => { + let nextPromise: Promise> | undefined + let next: NextFunction = () => { nextPromise = dispatch(i + 1) return nextPromise } - let response = await raceRequestAbort(Promise.resolve(fn(context, next)), context.request) + let result = await raceRequestAbort(Promise.resolve(fn(context, next)), context.request) - // If a response was returned, short-circuit the chain - if (response instanceof Response) { - return response + // If a result was returned, short-circuit the chain + if (result !== undefined) { + return result } - // If the middleware called next(), use the downstream response + // If the middleware called next(), use the downstream output if (nextPromise != null) { return nextPromise } - throw new Error('Middleware must return a Response or call next()') + throw new Error('Middleware must return a value or call next()') } return dispatch(0) diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index 953002f4902..fc0e8fb4ed1 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -73,10 +73,15 @@ type ContextFallbackValue = [ContextDefaultValue] extends [never] export declare const requestContextTypes: unique symbol -interface RequestContextTypes, entries extends ContextEntries> { +interface RequestContextTypes< + params extends Record, + entries extends ContextEntries, + output, +> { readonly [requestContextTypes]?: { params: params entries: entries + output: output } } @@ -84,10 +89,15 @@ interface RequestContextTypes, entries extend * Extracts the route params type from a {@link RequestContext}. */ export type ContextParams = - context extends RequestContextTypes, any> ? params : {} + context extends RequestContextTypes, any, any> + ? params + : {} type RequestContextEntries = - context extends RequestContextTypes ? entries : [] + context extends RequestContextTypes ? entries : [] + +type RequestContextOutput = + context extends RequestContextTypes ? output : Response /** * Resolves duplicate route params. Values in `right` win when present, but optional right-side @@ -113,12 +123,13 @@ export type MergeContextParams< * Adds route params to a {@link RequestContext} while preserving its existing context values. */ export type ContextWithParams> = - context extends RequestContextTypes + context extends RequestContextTypes ? RequestContextWithEntries< MergeContextParams, params>, - RequestContextEntries + RequestContextEntries, + RequestContextOutput > - : RequestContextWithEntries + : RequestContextWithEntries type ResolveEntryValue< entries extends ContextEntries, @@ -136,7 +147,7 @@ type ResolveEntryValue< * Resolves the value type returned by `context.get(key)` for the given context and key. */ export type GetContextValue = - context extends RequestContextTypes + context extends RequestContextTypes ? ResolveEntryValue, key, ContextFallbackValue> : ContextFallbackValue @@ -162,17 +173,19 @@ type ContextProperties = entries extends readonl type RequestContextWithEntries< params extends Record, entries extends ContextEntries, -> = RequestContext & ContextProperties + output, +> = RequestContext & ContextProperties /** * Appends context entries to an existing {@link RequestContext}. * This is useful when deriving a context shape without a middleware tuple. */ export type ContextWithEntries = - context extends RequestContextTypes + context extends RequestContextTypes ? RequestContextWithEntries< ContextParams, - [...RequestContextEntries, ...additions] + [...RequestContextEntries, ...additions], + RequestContextOutput > : never @@ -185,6 +198,14 @@ export type ContextWithEntry = ContextWithE [entry] > +/** + * Replaces the output type returned by the router associated with a request context. + */ +export type ContextWithOutput = + context extends RequestContextTypes + ? RequestContextWithEntries, RequestContextEntries, output> + : never + /** * A context object that contains information about the current request. Every request * handler or middleware in the lifecycle of a request receives the same context object. @@ -192,6 +213,7 @@ export type ContextWithEntry = ContextWithE export class RequestContext< params extends Record = {}, entries extends ContextEntries = [], + output = Response, > { /** * @param request The incoming request @@ -251,17 +273,27 @@ export class RequestContext< * @param key The key to read * @returns The value for the given key, or `undefined` if the value is not available */ - get = (key: key): GetContextValue, key> => { + get = ( + key: key, + ): GetContextValue, key> => { if (!this.#contextMap.has(key)) { - let contextKey = key as ContextKey, key>> + let contextKey = key as ContextKey< + GetContextValue, key> + > if (!Object.hasOwn(contextKey, 'defaultValue')) { - return undefined as GetContextValue, key> + return undefined as GetContextValue, key> } - return contextKey.defaultValue as GetContextValue, key> + return contextKey.defaultValue as GetContextValue< + RequestContext, + key + > } - return this.#contextMap.get(key) as GetContextValue, key> + return this.#contextMap.get(key) as GetContextValue< + RequestContext, + key + > } /** @@ -334,20 +366,20 @@ export class RequestContext< }) } - #router?: Router + #router?: Router /** * The router handling this request. */ - get router(): Router> { + get router(): Router, output> { if (this.#router == null) { throw new Error('No router found in request context.') } - return this.#router as Router> + return this.#router as Router, output> } - set router(router: Router) { + set router(router: Router) { this.#router = router } @@ -360,4 +392,5 @@ export class RequestContext< export interface RequestContext< params extends Record = {}, entries extends ContextEntries = [], -> extends RequestContextTypes {} + output = Response, +> extends RequestContextTypes {} diff --git a/packages/fetch-router/src/lib/router-types.test.ts b/packages/fetch-router/src/lib/router-types.test.ts index a6b038827fb..eac3cda36d5 100644 --- a/packages/fetch-router/src/lib/router-types.test.ts +++ b/packages/fetch-router/src/lib/router-types.test.ts @@ -10,6 +10,7 @@ import { type RouteBuilder, type RouteInstaller, type RouterContext, + type RouterOutput, } from './router.ts' import type { IsEqual } from './type-utils.ts' @@ -80,6 +81,9 @@ type AdminAppContext = ContextWithEntry< const elevatedReportMiddleware = createMiddleware(setRole('admin')) type ElevatedAppContext = MiddlewareContext +type TestNode = string | null | TestNode[] +type MaybeTestNode = TestNode | undefined + describe('router type inference', () => { it('keeps context values optional when middleware has not provided them', async () => { let plainRouter = createRouter() @@ -165,6 +169,104 @@ describe('router type inference', () => { void assertContext }) + it('uses the default output instead of inferring it from router options', () => { + class CustomResponse extends Response {} + + let router = createRouter({ + defaultHandler: () => new CustomResponse(), + }) + + type Output = RouterOutput + expectTypeEquality>() + + router.get('/', () => new Response('Home')) + }) + + it('propagates a custom output through routes, middleware, and controllers', () => { + let router = createRouter({ + defaultHandler: () => null, + middleware: [ + async (context, next) => { + let downstream = next() + let nestedOutput = context.router.fetch('https://remix.run/account/123') + + expectTypeEquality>>() + expectTypeEquality>>() + + return ['layout', await downstream] + }, + ], + }) + + type Output = RouterOutput + expectTypeEquality>() + + router.get('/', (context) => { + let nestedOutput = context.router.fetch('https://remix.run') + expectTypeEquality>>() + + return 'Home' + }) + + let controller = createController< + typeof routes.admin, + RequestContext, + readonly [], + MaybeTestNode + >(routes.admin, { + actions: { + dashboard(context) { + let nestedOutput = context.router.fetch('https://remix.run') + expectTypeEquality>>() + + return 'Dashboard' + }, + member(context) { + return ['Member', context.params.memberId] + }, + }, + }) + + router.map(routes.admin, controller) + + router.mount('/mounted', (mountedRouter) => { + type MountedOutput = RouterOutput + expectTypeEquality>() + + mountedRouter.get('/child', () => 'Child') + }) + + if (false as boolean) { + // @ts-expect-error - custom-output routes cannot return a Response + router.get('/response', () => new Response()) + + // @ts-expect-error - undefined is not a valid top-level router output + router.get('/undefined', () => undefined) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - middleware must return the custom output type + middleware: [() => new Response()], + }) + } + }) + + it('requires a default handler for custom outputs', () => { + if (false as boolean) { + // @ts-expect-error - only Response routers have a built-in default handler + createRouter() + } + }) + + it('uses the default output for stored middleware', () => { + if (false as boolean) { + createMiddleware( + // @ts-expect-error - stored middleware must return the default output type + () => 'invalid', + ) + } + }) + it('derives context from middleware factory return types', () => { let factoryMiddleware = createMiddleware(requireUser(), loadAdminRole(), setFormData()) diff --git a/packages/fetch-router/src/lib/router-types.ts b/packages/fetch-router/src/lib/router-types.ts index b5a36043948..26ae81adaef 100644 --- a/packages/fetch-router/src/lib/router-types.ts +++ b/packages/fetch-router/src/lib/router-types.ts @@ -3,15 +3,15 @@ import type { RequestContext } from './request-context.ts' /** * Ambient router type configuration for application-wide defaults. * - * Apps may augment this interface to define the default request context used by - * `createAction()` and `createController()`. Multi-router apps should avoid this - * global default and pass explicit context types instead. + * Apps may augment this interface to define the default request context and output used by router + * APIs. Multi-router apps should avoid these global defaults and pass explicit types instead. * * @example * ```ts * declare module '@remix-run/fetch-router' { * interface RouterTypes { * context: AppContext + * output: AppNode * } * } * ``` @@ -19,7 +19,9 @@ import type { RequestContext } from './request-context.ts' export interface RouterTypes {} export type DefaultContext = RouterTypes extends { - context: infer context extends RequestContext + context: infer context extends RequestContext } ? context : RequestContext + +export type DefaultOutput = RouterTypes extends { output: infer output } ? output : Response diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index df913eecd27..ea9b3276647 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -8,7 +8,68 @@ import type { NextFunction } from './middleware.ts' import type { RequestContext } from './request-context.ts' import { createRouter, type MatchData } from './router.ts' +type TestNode = string | null | TestNode[] + describe('router.fetch()', () => { + it('returns a custom output while preserving the Request context', async () => { + let routes = route({ home: '/' }) + let request = new Request('https://remix.run') + let router = createRouter({ + defaultHandler: () => null, + middleware: [async (_, next) => ['layout', await next()]], + }) + + router.map(routes, { + actions: { + home(context) { + assert.equal(context.request, request) + assert.equal(context.request.signal, request.signal) + return ['home'] + }, + }, + }) + + assert.deepEqual(await router.fetch(request), ['layout', ['home']]) + assert.deepEqual(await router.fetch('https://remix.run/missing'), ['layout', null]) + }) + + it('normalizes URL input to a Request and uses the init signal', async () => { + let controller = new AbortController() + let reason = new Error('Cancelled') + let requestSignal: AbortSignal | undefined + let startHandler: (() => void) | undefined + let handlerStarted = new Promise((resolve) => { + startHandler = resolve + }) + let router = createRouter({ + defaultHandler: () => null, + }) + + router.get('/account', async (context) => { + assert.equal(context.url.href, 'https://remix.run/account') + assert.equal(context.request.url, 'https://remix.run/account') + requestSignal = context.request.signal + startHandler?.() + + await new Promise((resolve) => { + context.request.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + + return null + }) + + let output = router.fetch(new URL('https://remix.run/account'), { + signal: controller.signal, + }) + + await handlerStarted + controller.abort(reason) + + await assert.rejects(output, reason) + assert.equal(requestSignal?.aborted, true) + assert.equal(requestSignal?.reason, reason) + }) + it('fetches a route', async () => { let router = createRouter() router.get('/', () => new Response('Home')) diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 40f0184a4d5..d0ff5a7a63b 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -9,12 +9,15 @@ import { import { type AnyMiddleware, type MiddlewareContext, runMiddleware } from './middleware.ts' import { raceRequestAbort } from './request-abort.ts' import { + type ContextWithOutput, type ContextWithParams, RequestContext, type requestContextTypes, } from './request-context.ts' import type { RequestMethod } from './request-methods.ts' import { type RouteMap, Route } from './route-map.ts' +import type { DefaultOutput } from './router-types.ts' +import type { Defined } from './type-utils.ts' import { type RequestHandler, type Action, @@ -24,7 +27,7 @@ import { isController, } from './controller.ts' -type AnyContext = RequestContext +type AnyContext = RequestContext type RouteTarget< pattern extends string = string, @@ -44,7 +47,7 @@ type ContextProvides = type ContextCompatibility< providedContext extends AnyContext, requiredContext extends AnyContext, - middleware extends readonly AnyMiddleware[], + middleware extends readonly AnyMiddleware[], > = [providedContext] extends [requiredContext] ? unknown : ContextProvides extends true @@ -53,14 +56,14 @@ type ContextCompatibility< ? unknown : never -type VerbMethod = { +type VerbMethod = { < pattern extends string, actionContext extends AnyContext = context, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], >( route: RouteTarget, - action: Action, actionContext, middleware> & + action: Action, actionContext, middleware, output> & ContextCompatibility, ): void } @@ -68,7 +71,7 @@ type VerbMethod = { /** * The normalized route entry stored in the router matcher. */ -export interface RouteEntry { +export interface RouteEntry { /** * The URL pattern used to match this route. */ @@ -76,7 +79,7 @@ export interface RouteEntry { /** * The handler that runs when this route matches. */ - handler: RequestHandler + handler: RequestHandler /** * The request method this route handles, or `ANY` for method-agnostic routes. */ @@ -84,14 +87,14 @@ export interface RouteEntry { /** * Action middleware that runs before the handler. */ - middleware: AnyMiddleware[] | undefined + middleware: AnyMiddleware[] | undefined } -export type MatchData = RouteEntry +export type MatchData = RouteEntry -type NormalizedAction = { - handler: RequestHandler - middleware: AnyMiddleware[] | undefined +type NormalizedAction = { + handler: RequestHandler + middleware: AnyMiddleware[] | undefined } type MapTarget = RouteTarget | RouteMap @@ -103,15 +106,17 @@ type MapTarget = RouteTarget | RouteMap export type MapHandler< target extends MapTarget, context extends AnyContext = RequestContext, - middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, + middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], > = - target extends string ? Action : - target extends RoutePattern ? Action, context, middleware> : - target extends Route ? Action : - target extends RouteMap ? Controller : + target extends string ? Action : + target extends RoutePattern ? Action, context, middleware, output> : + target extends Route ? Action : + target extends RouteMap ? Controller : never declare const routeBuilderContext: unique symbol +declare const routeBuilderOutput: unique symbol /** * A route builder registers routes into a router. @@ -119,8 +124,9 @@ declare const routeBuilderContext: unique symbol * Route builders are useful for composing route groups with {@link RouteInstaller}. Unlike a * {@link Router}, a route builder cannot dispatch requests. */ -export interface RouteBuilder { +export interface RouteBuilder { readonly [routeBuilderContext]?: context + readonly [routeBuilderOutput]?: output /** * Registers a handler for a specific request method and route target. * @@ -130,11 +136,11 @@ export interface RouteBuilder { method extends RequestMethod | 'ANY', pattern extends string, actionContext extends AnyContext = context, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], >( method: method, pattern: RouteTarget, - action: Action, actionContext, middleware> & + action: Action, actionContext, middleware, output> & ContextCompatibility, ): void /** @@ -143,10 +149,10 @@ export interface RouteBuilder { map< target extends MapTarget, handlerContext extends AnyContext = context, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], >( target: target, - handler: MapHandler & + handler: MapHandler & ContextCompatibility, ): void /** @@ -154,43 +160,46 @@ export interface RouteBuilder { */ mount( prefix: pattern | RoutePattern, - installer: RouteInstaller>, + installer: RouteInstaller, output>, ): void /** * Shorthand for registering a `GET` route. */ - get: VerbMethod<'GET', context> + get: VerbMethod<'GET', context, output> /** * Shorthand for registering a `HEAD` route. */ - head: VerbMethod<'HEAD', context> + head: VerbMethod<'HEAD', context, output> /** * Shorthand for registering a `POST` route. */ - post: VerbMethod<'POST', context> + post: VerbMethod<'POST', context, output> /** * Shorthand for registering a `PUT` route. */ - put: VerbMethod<'PUT', context> + put: VerbMethod<'PUT', context, output> /** * Shorthand for registering a `PATCH` route. */ - patch: VerbMethod<'PATCH', context> + patch: VerbMethod<'PATCH', context, output> /** * Shorthand for registering a `DELETE` route. */ - delete: VerbMethod<'DELETE', context> + delete: VerbMethod<'DELETE', context, output> /** * Shorthand for registering an `OPTIONS` route. */ - options: VerbMethod<'OPTIONS', context> + options: VerbMethod<'OPTIONS', context, output> } /** * A function that registers a route group into a route builder. */ -export interface RouteInstaller { - (router: RouteBuilder): void +export interface RouteInstaller< + context extends AnyContext = RequestContext, + output = DefaultOutput, +> { + (router: RouteBuilder): void } /** @@ -199,27 +208,41 @@ export interface RouteInstaller { * This is useful when you want to configure `RouterTypes.context` from a router that uses inline * middleware arrays. */ -export type RouterContext> = - router extends RouteBuilder ? context : never +export type RouterContext = router extends { + readonly [routeBuilderContext]?: infer context +} + ? context + : never + +/** + * Extracts the output type returned by a router or route builder. + */ +export type RouterOutput = router extends { readonly [routeBuilderOutput]?: infer output } + ? Defined + : never /** * Options for creating a router. */ export interface RouterOptions< context extends AnyContext = RequestContext, - middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + output = DefaultOutput, + middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], > { /** * The default request handler that runs when no route matches. - * Defaults to a 404 `Not Found` response. + * Response routers default to a 404 `Not Found` response. Routers with a custom output must + * provide this handler. */ - defaultHandler?: RequestHandler> + defaultHandler?: NoInfer< + RequestHandler>, output> + > /** * The matcher to use for matching routes. * * @default `createMultiMatcher()` */ - matcher?: MultiMatcher + matcher?: NoInfer>> /** * Middleware to run for every request handled by this router. * @@ -230,30 +253,33 @@ export interface RouterOptions< } /** - * A router maps incoming requests to request handlers. + * A router maps incoming requests to request handlers and returns their output. */ -export interface Router extends RouteBuilder { +export interface Router< + context extends AnyContext = RequestContext, + output = DefaultOutput, +> extends RouteBuilder { /** - * Fetch a response from the router. + * Fetch an output from the router. * * @param input The request input to fetch * @param init The request init options - * @returns The response from the route that matched the request + * @returns The output from the route that matched the request */ - fetch(input: string | URL | Request, init?: RequestInit): Promise + fetch(input: string | URL | Request, init?: RequestInit): Promise> } function noMatchHandler({ url }: RequestContext): Response { return new Response(`Not Found: ${url.pathname}`, { status: 404 }) } -function normalizeMiddleware( - middleware: readonly AnyMiddleware[] | undefined, -): AnyMiddleware[] | undefined { +function normalizeMiddleware( + middleware: readonly AnyMiddleware[] | undefined, +): AnyMiddleware[] | undefined { return middleware == null || middleware.length === 0 ? undefined : [...middleware] } -function normalizeAction(action: unknown): NormalizedAction { +function normalizeAction(action: unknown): NormalizedAction { if (isRequestHandler(action)) { return { handler: action, @@ -273,10 +299,10 @@ function normalizeAction(action: unknown): NormalizedAction { } } -function mergeMiddleware( - upstream: AnyMiddleware[] | undefined, - downstream: AnyMiddleware[] | undefined, -): AnyMiddleware[] | undefined { +function mergeMiddleware( + upstream: AnyMiddleware[] | undefined, + downstream: AnyMiddleware[] | undefined, +): AnyMiddleware[] | undefined { if (!upstream || upstream.length === 0) { return downstream } @@ -292,14 +318,17 @@ function isRouteTarget(target: MapTarget): target is RouteTarget { return typeof target === 'string' || target instanceof Route || target instanceof RoutePattern } -function createRequestContext(input: string | URL | Request, init?: RequestInit): RequestContext { +function createRequestContext( + input: string | URL | Request, + init?: RequestInit, +): RequestContext<{}, [], output> { let request = input instanceof Request && init == null ? input : new Request(input, init) if (request.signal.aborted) { throw request.signal.reason } - return new RequestContext(request) + return new RequestContext<{}, [], output>(request) } function getRoutePattern(target: RouteTarget): RoutePattern { @@ -333,17 +362,45 @@ function getPrefixedRoutePattern(target: RouteTarget, state: BuilderState): Rout * @param options Options to configure the router * @returns The new router */ +type CreateRouterArgs< + context extends AnyContext, + output, + middleware extends readonly AnyMiddleware[], +> = [Defined] extends [Response] + ? [options?: RouterOptions] + : [ + options: RouterOptions & { + defaultHandler: NoInfer< + RequestHandler>, output> + > + }, + ] + export function createRouter< context extends AnyContext = RequestContext, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], ->(options?: RouterOptions): Router> { - type RouterContext = MiddlewareContext - - let defaultHandler = (options?.defaultHandler ?? noMatchHandler) as RequestHandler - let matcher = options?.matcher ?? createMultiMatcher() + output = DefaultOutput, + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], +>( + ...args: CreateRouterArgs +): Router>, output> +export function createRouter(): Router +export function createRouter< + context extends AnyContext = RequestContext, + output = DefaultOutput, + const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], +>( + ...args: CreateRouterArgs +): Router>, output> { + type RouterContext = MiddlewareContext> + + let options = args[0] + let defaultHandler = (options?.defaultHandler ?? noMatchHandler) as RequestHandler + let matcher = options?.matcher ?? createMultiMatcher>() let routerMiddleware = normalizeMiddleware(options?.middleware) - async function dispatchRouter(context: RequestContext): Promise { + async function dispatchRouter( + context: RequestContext, + ): Promise> { let dispatch = () => dispatchMatches(context) if (routerMiddleware) { @@ -353,7 +410,9 @@ export function createRouter< return dispatch() } - async function dispatchMatches(context: RequestContext): Promise { + async function dispatchMatches( + context: RequestContext, + ): Promise> { for (let match of matcher.matchAll(context.url)) { let route = match.data @@ -376,11 +435,11 @@ export function createRouter< function registerRoute( method: RequestMethod | 'ANY', route: RouteTarget, - action: NormalizedAction, + action: NormalizedAction, state: BuilderState, ): void { let pattern = getPrefixedRoutePattern(route, state) - let entry: RouteEntry = { + let entry: RouteEntry = { pattern, handler: action.handler, method, @@ -396,7 +455,7 @@ export function createRouter< action: unknown, state: BuilderState, ): void { - registerRoute(method, route, normalizeAction(action), state) + registerRoute(method, route, normalizeAction(action), state) } function mapRoutes(target: MapTarget, handler: unknown, state: BuilderState): void { @@ -413,13 +472,13 @@ export function createRouter< } function mapSingleRoute(target: RouteTarget, handler: unknown, state: BuilderState): void { - registerRoute(getMappedRouteMethod(target), target, normalizeAction(handler), state) + registerRoute(getMappedRouteMethod(target), target, normalizeAction(handler), state) } function mapController( routes: RouteMap, controller: { - middleware?: readonly AnyMiddleware[] | undefined + middleware?: readonly AnyMiddleware[] | undefined actions: Record }, state: BuilderState, @@ -446,7 +505,7 @@ export function createRouter< throw new TypeError(`Missing action \`${key}\` in controller`) } - let action = normalizeAction(controller.actions[key]) + let action = normalizeAction(controller.actions[key]) registerRoute( route.method, route, @@ -462,15 +521,16 @@ export function createRouter< function createRouteBuilder( state: BuilderState, - ): RouteBuilder { + ): RouteBuilder { function createVerbMethod(method: method) { return < pattern extends string, actionContext extends AnyContext = builderContext, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + const middleware extends readonly AnyMiddleware[] = + readonly AnyMiddleware[], >( route: RouteTarget, - action: Action, actionContext, middleware> & + action: Action, actionContext, middleware, output> & ContextCompatibility, ): void => { addRoute(method, route, action, state) @@ -482,11 +542,12 @@ export function createRouter< method extends RequestMethod | 'ANY', pattern extends string, actionContext extends AnyContext = builderContext, - const middleware extends readonly AnyMiddleware[] = readonly AnyMiddleware[], + const middleware extends readonly AnyMiddleware[] = + readonly AnyMiddleware[], >( method: method, route: RouteTarget, - action: Action, actionContext, middleware> & + action: Action, actionContext, middleware, output> & ContextCompatibility, ): void { addRoute(method, route, action, state) @@ -496,7 +557,7 @@ export function createRouter< }, mount( prefix: pattern | RoutePattern, - installer: RouteInstaller>, + installer: RouteInstaller, output>, ): void { let mountPrefix = typeof prefix === 'string' ? RoutePattern.parse(prefix) : prefix let childPrefix = state.prefix ? joinPatterns(state.prefix, mountPrefix) : mountPrefix @@ -516,10 +577,10 @@ export function createRouter< let rootBuilder = createRouteBuilder({ prefix: undefined }) - let router: Router = { + let router: Router = { ...rootBuilder, - fetch(input: string | URL | Request, init?: RequestInit): Promise { - let context = createRequestContext(input, init) + fetch(input: string | URL | Request, init?: RequestInit): Promise> { + let context = createRequestContext(input, init) context.router = router return dispatchRouter(context) diff --git a/packages/fetch-router/src/lib/type-utils.ts b/packages/fetch-router/src/lib/type-utils.ts index 7182ca388c9..151dd13c3aa 100644 --- a/packages/fetch-router/src/lib/type-utils.ts +++ b/packages/fetch-router/src/lib/type-utils.ts @@ -1,5 +1,7 @@ export type Assert = T +export type Defined = Exclude + export type IsEqual = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false diff --git a/packages/logger-middleware/.changes/patch.response-output.md b/packages/logger-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..4c5853fb418 --- /dev/null +++ b/packages/logger-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `logger()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/logger-middleware/src/lib/logger.test.ts b/packages/logger-middleware/src/lib/logger.test.ts index 68c7e63d2fc..0c6382c2ac0 100644 --- a/packages/logger-middleware/src/lib/logger.test.ts +++ b/packages/logger-middleware/src/lib/logger.test.ts @@ -206,6 +206,18 @@ describe('logger', () => { assert.equal(message, 'bad') }) }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ middleware: [logger({ log() {} })] }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('logger() expected next() to return a Response'), + ) + }) }) async function logRequest({ diff --git a/packages/logger-middleware/src/lib/logger.ts b/packages/logger-middleware/src/lib/logger.ts index 4b27ffc4f8a..a1b28fdd627 100644 --- a/packages/logger-middleware/src/lib/logger.ts +++ b/packages/logger-middleware/src/lib/logger.ts @@ -82,7 +82,7 @@ export interface LoggerOptions { */ export function logger( options: LoggerOptions = {}, -): Middleware<{ key: typeof Logger; value: LoggerFunction; property: 'logger' }> { +): Middleware<{ key: typeof Logger; value: LoggerFunction; property: 'logger' }, Response> { let { colors, format = '[%date] %method %path %status %contentLength', @@ -96,6 +96,7 @@ export function logger( let { request, url } = context let start = new Date() let response = await next() + assertResponse(response) let end = new Date() let duration = end.getTime() - start.getTime() let contentLength = response.headers.get('Content-Length') @@ -131,6 +132,12 @@ export function logger( } } +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('logger() expected next() to return a Response') + } +} + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] interface Colorizer { diff --git a/packages/remix/.changes/minor.fetch-router.generic-router-output.md b/packages/remix/.changes/minor.fetch-router.generic-router-output.md new file mode 100644 index 00000000000..4fcf594f614 --- /dev/null +++ b/packages/remix/.changes/minor.fetch-router.generic-router-output.md @@ -0,0 +1 @@ +Expose generic router output contracts through the `remix/router` and `remix/fetch-router` entrypoints, including URL-to-node routers that retain `RequestContext` and `router.fetch(url, { signal })` cancellation. Response-dependent middleware is rejected by custom-output routers. diff --git a/packages/remix/.changes/minor.ui.spa.md b/packages/remix/.changes/minor.ui.spa.md new file mode 100644 index 00000000000..a835c21cecf --- /dev/null +++ b/packages/remix/.changes/minor.ui.spa.md @@ -0,0 +1 @@ +Export an `SPA` component and `createSPA` setup utility from `remix/ui/spa` that render router outputs during client-side navigation and respect `rmx-document` and `rmx-history` navigation attributes. diff --git a/packages/remix/manifest.json b/packages/remix/manifest.json index 3f7380a3f54..d769d739e00 100644 --- a/packages/remix/manifest.json +++ b/packages/remix/manifest.json @@ -104,6 +104,7 @@ "remix/ui/select": "@remix-run/ui/select", "remix/ui/select/primitives": "@remix-run/ui/select/primitives", "remix/ui/server": "@remix-run/ui/server", + "remix/ui/spa": "@remix-run/ui/spa", "remix/ui/tabs": "@remix-run/ui/tabs", "remix/ui/tabs/primitives": "@remix-run/ui/tabs/primitives", "remix/ui/test": "@remix-run/ui/test", diff --git a/packages/remix/package.json b/packages/remix/package.json index 28dbf6fd7a7..d0c476984ec 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -126,6 +126,7 @@ "./ui/select": "./src/ui/select.ts", "./ui/select/primitives": "./src/ui/select/primitives.ts", "./ui/server": "./src/ui/server.ts", + "./ui/spa": "./src/ui/spa.ts", "./ui/tabs": "./src/ui/tabs.ts", "./ui/tabs/primitives": "./src/ui/tabs/primitives.ts", "./ui/test": "./src/ui/test.ts", @@ -555,6 +556,10 @@ "types": "./dist/ui/server.d.ts", "default": "./dist/ui/server.js" }, + "./ui/spa": { + "types": "./dist/ui/spa.d.ts", + "default": "./dist/ui/spa.js" + }, "./ui/tabs": { "types": "./dist/ui/tabs.d.ts", "default": "./dist/ui/tabs.js" diff --git a/packages/remix/src/ui/spa.ts b/packages/remix/src/ui/spa.ts new file mode 100644 index 00000000000..d354af55889 --- /dev/null +++ b/packages/remix/src/ui/spa.ts @@ -0,0 +1,2 @@ +// IMPORTANT: This file is auto-generated, please do not edit manually. +export * from '@remix-run/ui/spa' diff --git a/packages/remix/type-tests/response-middleware.ts b/packages/remix/type-tests/response-middleware.ts new file mode 100644 index 00000000000..c6fdbb637e1 --- /dev/null +++ b/packages/remix/type-tests/response-middleware.ts @@ -0,0 +1,119 @@ +import { asyncContext } from 'remix/middleware/async-context' +import { auth, type requireAuth } from 'remix/middleware/auth' +import type { compression } from 'remix/middleware/compression' +import type { cop } from 'remix/middleware/cop' +import type { cors } from 'remix/middleware/cors' +import type { csrf } from 'remix/middleware/csrf' +import { formData } from 'remix/middleware/form-data' +import type { logger } from 'remix/middleware/logger' +import { methodOverride } from 'remix/middleware/method-override' +import { renderWith } from 'remix/middleware/render' +import type { session } from 'remix/middleware/session' +import type { staticFiles } from 'remix/middleware/static' +import { createRouter } from 'remix/router' +import type { Middleware } from 'remix/router' +import type { RemixNode } from 'remix/ui' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +declare const authMiddleware: ReturnType +declare const compressionMiddleware: ReturnType +declare const copMiddleware: ReturnType +declare const corsMiddleware: ReturnType +declare const csrfMiddleware: ReturnType +declare const loggerMiddleware: ReturnType +declare const sessionMiddleware: ReturnType +declare const staticMiddleware: ReturnType + +type MiddlewareOutput = + middleware extends Middleware ? output : never +type IsEqual = + (() => type extends left ? 1 : 2) extends () => type extends right ? 1 : 2 + ? true + : false + +declare function expectResponseMiddleware( + middleware: middleware & + (IsEqual, Response> extends true ? unknown : never), +): void + +if (false as boolean) { + expectResponseMiddleware(authMiddleware) + expectResponseMiddleware(compressionMiddleware) + expectResponseMiddleware(copMiddleware) + expectResponseMiddleware(corsMiddleware) + expectResponseMiddleware(csrfMiddleware) + expectResponseMiddleware(loggerMiddleware) + expectResponseMiddleware(sessionMiddleware) + expectResponseMiddleware(staticMiddleware) + + createRouter({ + defaultHandler: () => null, + middleware: [ + asyncContext(), + auth({ + schemes: [ + { + name: 'test', + authenticate: () => null, + }, + ], + }), + formData(), + methodOverride(), + renderWith(() => () => new Response()), + ], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - requireAuth middleware requires Response output + middleware: [authMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - compression middleware requires Response output + middleware: [compressionMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - COP middleware requires Response output + middleware: [copMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - CORS middleware requires Response output + middleware: [corsMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - CSRF middleware requires Response output + middleware: [csrfMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - logger middleware requires Response output + middleware: [loggerMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - session middleware requires Response output + middleware: [sessionMiddleware], + }) + + createRouter({ + defaultHandler: () => null, + // @ts-expect-error - static middleware requires Response output + middleware: [staticMiddleware], + }) +} diff --git a/packages/render-middleware/.changes/patch.custom-router-outputs.md b/packages/render-middleware/.changes/patch.custom-router-outputs.md new file mode 100644 index 00000000000..92132131a97 --- /dev/null +++ b/packages/render-middleware/.changes/patch.custom-router-outputs.md @@ -0,0 +1 @@ +Keep `renderWith()` compatible with custom router output types. diff --git a/packages/render-middleware/src/lib/render.ts b/packages/render-middleware/src/lib/render.ts index d4f59d258b8..675f7a14fb9 100644 --- a/packages/render-middleware/src/lib/render.ts +++ b/packages/render-middleware/src/lib/render.ts @@ -25,7 +25,9 @@ export type AnyRenderer = Renderer */ export const Renderer: { defaultValue?: AnyRenderer } = createContextKey() -type RendererFactory = (context: RequestContext) => renderer +type RendererFactory = ( + context: RequestContext, +) => renderer /** * Adds a renderer to request context. diff --git a/packages/session-middleware/.changes/patch.response-output.md b/packages/session-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..4285bf88e50 --- /dev/null +++ b/packages/session-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `session()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/session-middleware/src/lib/session.test.ts b/packages/session-middleware/src/lib/session.test.ts index e533a010ed9..cc615787f0f 100644 --- a/packages/session-middleware/src/lib/session.test.ts +++ b/packages/session-middleware/src/lib/session.test.ts @@ -173,4 +173,20 @@ describe('session middleware', () => { let setCookie = response.headers.getSetCookie() assert.equal(setCookie.length, 2) }) + + it('throws when the next handler does not return a Response', async () => { + let cookie = createCookie('__sess', { secrets: ['secret1'] }) + let storage = createCookieSessionStorage() + let router = createRouter({ + middleware: [sessionMiddleware(cookie, storage)], + }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.get('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/'), + new TypeError('session() expected next() to return a Response'), + ) + }) }) diff --git a/packages/session-middleware/src/lib/session.ts b/packages/session-middleware/src/lib/session.ts index a491ea50997..6a53e7be340 100644 --- a/packages/session-middleware/src/lib/session.ts +++ b/packages/session-middleware/src/lib/session.ts @@ -12,7 +12,7 @@ import { Session, type SessionStorage } from '@remix-run/session' export function session( sessionCookie: Cookie, sessionStorage: SessionStorage, -): Middleware<{ key: typeof Session; value: Session; property: 'session' }> { +): Middleware<{ key: typeof Session; value: Session; property: 'session' }, Response> { if (!sessionCookie.signed) { throw new Error('Session cookie must be signed') } @@ -34,6 +34,7 @@ export function session( context.set(Session, session, { property: 'session' }) let response = await next() + assertResponse(response) if (session !== context.get(Session)) { throw new Error('Cannot save session that was initialized by another middleware/handler') @@ -54,3 +55,9 @@ export function session( return response } } + +function assertResponse(value: unknown): asserts value is Response { + if (!(value instanceof Response)) { + throw new TypeError('session() expected next() to return a Response') + } +} diff --git a/packages/static-middleware/.changes/patch.response-output.md b/packages/static-middleware/.changes/patch.response-output.md new file mode 100644 index 00000000000..f07daae357a --- /dev/null +++ b/packages/static-middleware/.changes/patch.response-output.md @@ -0,0 +1 @@ +Declare `staticFiles()` as Response-only middleware and throw a clear `TypeError` if a downstream handler returns a non-`Response` value. diff --git a/packages/static-middleware/src/lib/static.test.ts b/packages/static-middleware/src/lib/static.test.ts index 3002cd0206b..39fc9abecc2 100644 --- a/packages/static-middleware/src/lib/static.test.ts +++ b/packages/static-middleware/src/lib/static.test.ts @@ -751,4 +751,16 @@ describe('staticFiles middleware', () => { assert.equal(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') }) }) + + it('throws when the next handler does not return a Response', async () => { + let router = createRouter({ middleware: [staticFiles(tmpDir)] }) + + // @ts-expect-error - exercise runtime validation for JavaScript consumers + router.post('/', () => 'not a response') + + await assert.rejects( + () => router.fetch('https://remix.run/', { method: 'POST' }), + new TypeError('staticFiles() expected next() to return a Response'), + ) + }) }) diff --git a/packages/static-middleware/src/lib/static.ts b/packages/static-middleware/src/lib/static.ts index 7a07761e0e6..66dd7f860c5 100644 --- a/packages/static-middleware/src/lib/static.ts +++ b/packages/static-middleware/src/lib/static.ts @@ -86,7 +86,10 @@ export interface StaticFilesOptions extends Omit { // Ensure root is an absolute path root = path.resolve(root) @@ -103,27 +106,35 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid } return async (context, next) => { + let nextResponse = async (): Promise => { + let response = await next() + if (!(response instanceof Response)) { + throw new TypeError('staticFiles() expected next() to return a Response') + } + return response + } + if (context.method !== 'GET' && context.method !== 'HEAD') { - return next() + return nextResponse() } let relativePath = context.url.pathname.replace(/^\/+/, '') if (filter && !filter(relativePath)) { - return next() + return nextResponse() } let rootRealPath: string try { rootRealPath = await fsp.realpath(root) } catch { - return next() + return nextResponse() } let targetPath = path.join(root, relativePath) let containedTargetPath = await resolveContainedPath(rootRealPath, targetPath) if (containedTargetPath == null) { - return next() + return nextResponse() } let file: StaticFileMatch | undefined @@ -188,7 +199,7 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid return sendFile(lazyFile, context.request, finalFileOptions) } - return next() + return nextResponse() } } diff --git a/packages/ui/.changes/minor.spa.md b/packages/ui/.changes/minor.spa.md new file mode 100644 index 00000000000..ff0be367b14 --- /dev/null +++ b/packages/ui/.changes/minor.spa.md @@ -0,0 +1,19 @@ +Export an `SPA` component and `createSPA` setup utility from `remix/ui/spa` that render same-origin browser navigations through a URL-to-`RemixNode` router, expose active and pending URLs, forward cancellation signals, dispatch intercepted form submissions with their `FormData`, and respect `rmx-document` and `rmx-history` navigation attributes. + +```tsx +import { createRouter } from 'remix/router' +import { createRoot, type RemixNode } from 'remix/ui' +import { SPA } from 'remix/ui/spa' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +let router = createRouter({ defaultHandler: () =>

Not Found

}) +router.get('/', () =>

Home

) + +let root = createRoot(document.getElementById('app')!) +root.render(Loading...

} />) +``` diff --git a/packages/ui/README.md b/packages/ui/README.md index 5051de3b676..26b60c29e6b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,6 +5,7 @@ Runtime UI primitives for Remix apps, including the component runtime, server re ## Features - Component runtime APIs for rendering, hydration, link and form frame navigation, and JSX +- A client-only `SPA` component that renders URLs through a URL-to-node router - Server rendering APIs for streaming Remix UI trees and frames - `mix` composition with event, ref, CSS, and animation helpers - Headless behavior primitives for controls such as menus, listboxes, popovers, selects, and comboboxes @@ -140,6 +141,28 @@ Native constraint validation and submitter overrides still apply. GET form value Use `rmx-history="push|replace"` on an enhanced anchor or form to control how the navigation updates history. This can override the automatic replacement used for non-GET form submissions to the current URL. +Use `SPA` with a router that maps URLs directly to Remix UI nodes: + +```tsx +import { createRouter } from 'remix/router' +import { createRoot, type RemixNode } from 'remix/ui' +import { SPA } from 'remix/ui/spa' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +let router = createRouter({ defaultHandler: () => null }) +router.get('/', () =>

Home

) + +let root = createRoot(document.body) +root.render() +``` + +`SPA` intercepts same-origin browser navigations, exposes the active and pending URLs through its component context, and forwards navigation cancellation to `router.fetch(url, { signal })`. It preserves the native method for intercepted form submissions and forwards `FormData` as the body of non-GET requests. Navigation history entries do not retain submitted `FormData`, so back and forward navigations revisit form destinations with GET requests. Form destinations that handle non-GET submissions should therefore also accept GET. + ## Preserving Client-Owned DOM Use `rmx-preserve-dom` on the smallest element whose live DOM should belong to client code after initial render, such as a custom element or third-party widget: diff --git a/packages/ui/package.json b/packages/ui/package.json index 4914fab0014..602a19db01f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -107,6 +107,10 @@ "types": "./src/server/stream.ts", "default": "./src/server/stream.ts" }, + "./spa": { + "types": "./src/spa/index.ts", + "default": "./src/spa/index.ts" + }, "./tabs": { "types": "./src/tabs/index.tsx", "default": "./src/tabs/index.tsx" @@ -215,6 +219,10 @@ "types": "./dist/server/stream.d.ts", "default": "./dist/server/stream.js" }, + "./spa": { + "types": "./dist/spa/index.d.ts", + "default": "./dist/spa/index.js" + }, "./tabs": { "types": "./dist/tabs/index.d.ts", "default": "./dist/tabs/index.js" diff --git a/packages/ui/src/runtime/navigation-event.ts b/packages/ui/src/runtime/navigation-event.ts new file mode 100644 index 00000000000..cdd40ca7840 --- /dev/null +++ b/packages/ui/src/runtime/navigation-event.ts @@ -0,0 +1,76 @@ +type SourceElementNavigateEvent = NavigateEvent & { + sourceElement?: Element | null +} + +interface NavigationPrecommitControllerLike { + redirect(url: string, options: { history: 'replace' }): void +} + +interface NavigationInterceptOptionsWithPrecommit extends NavigationInterceptOptions { + precommitHandler(controller: NavigationPrecommitControllerLike): void +} + +export type NavigationReplacement = + | { + type: 'navigation' + state?: unknown + } + | { + type: 'form-submission' + info: unknown + state?: unknown + } + +export function getLinkNavigationElement(event: NavigateEvent): Element | undefined { + let sourceElement = (event as SourceElementNavigateEvent).sourceElement + if (!(sourceElement instanceof Element)) return + + let linkElement = sourceElement.closest('a, area') + return linkElement instanceof Element ? linkElement : undefined +} + +export function getReplaceHistory(value: string | null, defaultValue: boolean): boolean { + if (value === 'replace') return true + if (value === 'push') return false + return defaultValue +} + +export function interceptNavigation( + event: NavigateEvent, + options: { + handler(): Promise + replacement: NavigationReplacement | undefined + }, +): void { + let replacement = options.replacement + if (replacement == null) { + event.intercept({ handler: options.handler }) + return + } + + if ( + replacement.type === 'form-submission' && + typeof Reflect.get(window, 'NavigationPrecommitController') === 'function' + ) { + let interceptOptions: NavigationInterceptOptionsWithPrecommit = { + handler: options.handler, + precommitHandler(controller) { + controller.redirect(event.destination.url, { history: 'replace' }) + }, + } + event.intercept(interceptOptions) + return + } + + if (event.cancelable) { + event.preventDefault() + window.navigation.navigate(event.destination.url, { + history: 'replace', + info: replacement.type === 'form-submission' ? replacement.info : undefined, + state: replacement.state, + }) + return + } + + event.intercept({ handler: options.handler }) +} diff --git a/packages/ui/src/runtime/navigation.ts b/packages/ui/src/runtime/navigation.ts index 114f9a253bc..094da707faf 100644 --- a/packages/ui/src/runtime/navigation.ts +++ b/packages/ui/src/runtime/navigation.ts @@ -1,14 +1,11 @@ import { getTopFrame, getNamedFrame } from './run.ts' import { createFormNavigationResolver, type FormSubmission } from './form-navigation.ts' - -interface NavigationPrecommitControllerLike { - redirect(url: string, options: { history: 'replace' }): void -} - -interface NavigationInterceptOptionsWithPrecommit extends NavigationInterceptOptions { - handler(): Promise - precommitHandler(controller: NavigationPrecommitControllerLike): void -} +import { + getLinkNavigationElement, + getReplaceHistory, + interceptNavigation, + type NavigationReplacement, +} from './navigation-event.ts' type NavigationState = { target: string | undefined @@ -17,10 +14,6 @@ type NavigationState = { $rmx: true } -type SourceElementNavigateEvent = NavigateEvent & { - sourceElement?: Element | null -} - type RuntimeNavigation = { state: NavigationState getSubmission?: () => Promise @@ -155,50 +148,22 @@ export function startNavigationListenerImpl( } } - if (runtimeNavigation.getSubmission) { - //
navigations - if (runtimeNavigation.replaceHistory && replayedSubmission == null) { - let supportsPrecommit = - typeof Reflect.get(window, 'NavigationPrecommitController') === 'function' - - // Modern browsers allow you to update the in-flight navigation entry before it's committed - if (supportsPrecommit) { - let interceptOptions: NavigationInterceptOptionsWithPrecommit = { - handler, - precommitHandler(controller) { - controller.redirect(event.destination.url, { history: 'replace' }) - }, - } - event.intercept(interceptOptions) - return - } - - // Safari doesn't support precommit as of Aug 2026, so we do a full replacement navigation - if (event.cancelable) { - event.preventDefault() - window.navigation.navigate(event.destination.url, { - history: 'replace', + let replacement: NavigationReplacement | undefined + if (runtimeNavigation.replaceHistory && replayedSubmission == null) { + replacement = runtimeNavigation.getSubmission + ? { + type: 'form-submission', state, info: { type: formSubmissionNavigationInfoType, state, getSubmission: runtimeNavigation.getSubmission, } satisfies FormSubmissionNavigationInfo, - }) - return - } - } - - event.intercept({ handler }) - } else { - // / navigations - if (runtimeNavigation.replaceHistory && event.cancelable) { - event.preventDefault() - navigation.navigate(event.destination.url, { history: 'replace', state }) - } else { - event.intercept({ handler }) - } + } + : { type: 'navigation', state } } + + interceptNavigation(event, { handler, replacement }) }, { signal }, ) @@ -275,12 +240,8 @@ function getSourceElementNavigation( event: NavigateEvent, resolveFormNavigation: ReturnType, ): RuntimeNavigation | undefined { - let sourceEvent = event as SourceElementNavigateEvent - let sourceElement = sourceEvent.sourceElement - if (!(sourceElement instanceof Element)) return - - let linkElement = sourceElement.closest('a, area') - if (linkElement instanceof Element) { + let linkElement = getLinkNavigationElement(event) + if (linkElement) { if (linkElement.hasAttribute('rmx-document')) return if (linkElement.hasAttribute('download')) return @@ -316,9 +277,3 @@ function getSourceElementNavigation( getSubmission: formNavigation.getSubmission, } } - -function getReplaceHistory(value: string | null, defaultValue: boolean): boolean { - if (value === 'replace') return true - if (value === 'push') return false - return defaultValue -} diff --git a/packages/ui/src/spa/README.md b/packages/ui/src/spa/README.md new file mode 100644 index 00000000000..cbfc63ad0b0 --- /dev/null +++ b/packages/ui/src/spa/README.md @@ -0,0 +1,40 @@ +# SPA + +`SPA` renders same-origin browser navigations through a router that maps URLs to Remix UI nodes. + +```tsx +import { createRouter } from 'remix/router' +import { createRoot, type RemixNode } from 'remix/ui' +import { SPA } from 'remix/ui/spa' + +declare module 'remix/router' { + interface RouterTypes { + output: RemixNode + } +} + +let router = createRouter({ defaultHandler: () => null }) +router.get('/', () =>

Home

) + +let root = createRoot(document.body) +root.render() +``` + +`SPA` intercepts same-origin browser navigations, exposes the active and pending URLs through its component context, and forwards navigation cancellation to `router.fetch(url, { signal })`. It preserves the native method for intercepted form submissions and forwards `FormData` as the body of non-GET requests. + +Add `rmx-document` to a link or form to bypass SPA interception. Use `rmx-history="push|replace"` to override whether the navigation pushes or replaces the current history entry. + +Use `createSPA` in the setup scope of a custom top-level component when it needs to compose the rendered node or navigation state: + +```tsx +import type { Handle } from 'remix/ui' +import { createSPA } from 'remix/ui/spa' + +function App(handle: Handle) { + let spa = createSPA(handle, { router, fallback: 'Loading…' }) + + return () =>
{spa.node}
+} +``` + +Navigation history entries do not retain submitted `FormData`, so back and forward navigations revisit form destinations with GET requests. Form destinations that handle non-GET submissions should therefore also accept GET. diff --git a/packages/ui/src/spa/index.test.tsx b/packages/ui/src/spa/index.test.tsx new file mode 100644 index 00000000000..7d9bf9be73a --- /dev/null +++ b/packages/ui/src/spa/index.test.tsx @@ -0,0 +1,509 @@ +import { expect } from '@remix-run/assert' +import { describe, it, type TestContext } from '@remix-run/test' +import type { Handle, RemixNode } from '@remix-run/ui' +import { createSPA, SPA, type SPAMeta, type SPAProps } from '@remix-run/ui/spa' +import { render } from '@remix-run/ui/test' + +async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (condition()) return + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error('Timed out waiting for condition') +} + +function restoreLocationAfterTest(t: TestContext): void { + let href = window.location.href + t.after(() => history.replaceState(null, '', href)) +} + +function RouterState(handle: Handle) { + let router = handle.context.get(SPA) + return () => ( +

+ {router.active.pathname}|{router.pending?.pathname ?? 'idle'} +

+ ) +} + +function PageWithLink( + handle: Handle<{ + href: string + document?: boolean + history?: 'push' | 'replace' + }>, +) { + return () => ( + <> + +
+ Next + + + ) +} + +function waitForNavigationSuccess(): Promise { + let navigationSucceeded = Promise.withResolvers() + window.navigation.addEventListener('navigatesuccess', () => navigationSucceeded.resolve(), { + once: true, + }) + return navigationSucceeded.promise +} + +function CustomSPA(handle: Handle) { + let spa = createSPA(handle, handle.props) + let context = spa.context + return () => ( +
+ {spa.node} +
+ ) +} + +describe('SPA', () => { + it('exposes stable runtime-readonly SPA state', (t) => { + let created: { current?: SPAMeta } = {} + + function TestSPA(handle: Handle) { + let spa = createSPA(handle, handle.props) + created.current = spa + return () => spa.node + } + + let router: SPAProps['router'] = { + async fetch() { + return 'Page' + }, + } + let { cleanup } = render() + t.after(cleanup) + + let spa = created.current + if (!spa) throw new Error('Expected SPA state') + + let context = spa.context + expect(spa.context).toBe(context) + expect(Reflect.set(spa, 'context', context)).toBe(false) + expect(Reflect.set(spa, 'node', 'Other')).toBe(false) + }) + + it('composes SPA navigation into a custom component', async (t) => { + let page = Promise.withResolvers() + let router: SPAProps['router'] = { + fetch() { + return page.promise + }, + } + let { $, act, cleanup } = render() + t.after(cleanup) + + expect($('main')?.textContent).toBe('Loading') + expect($('main')?.getAttribute('data-pending')).toBe(window.location.pathname) + + await act(() => page.resolve('Page')) + + expect($('main')?.textContent).toBe('Page') + expect($('main')?.getAttribute('data-active')).toBe(window.location.pathname) + expect($('main')?.hasAttribute('data-pending')).toBe(false) + }) + + it('provides the active and pending URLs while loading the initial page', async (t) => { + let page = Promise.withResolvers() + let router: SPAProps['router'] = { + fetch() { + return page.promise + }, + } + let { container, act, cleanup } = render(} />) + t.after(cleanup) + + expect(container.textContent).toBe(`${window.location.pathname}|${window.location.pathname}`) + + await act(() => page.resolve()) + + expect(container.textContent).toBe(`${window.location.pathname}|idle`) + }) + + it('tracks active and pending URLs during link navigation', async (t) => { + restoreLocationAfterTest(t) + + let activePathname = window.location.pathname + let destination = new URL('/next', window.location.href) + let initialPage = Promise.withResolvers() + let nextPage = Promise.withResolvers() + let nextLoadStarted = Promise.withResolvers() + let router: SPAProps['router'] = { + fetch(url) { + if (url.href === destination.href) { + nextLoadStarted.resolve() + return nextPage.promise + } + + return initialPage.promise + }, + } + let { $, act, cleanup } = render() + t.after(cleanup) + await act(() => initialPage.resolve()) + + expect($('p')?.textContent).toBe(`${activePathname}|idle`) + + let navigationSucceeded = new Promise((resolve) => { + window.navigation.addEventListener('navigatesuccess', () => resolve(), { once: true }) + }) + let link = $('a') + if (!link) throw new Error('Expected link') + await act(async () => { + link.click() + await nextLoadStarted.promise + }) + + expect($('p')?.textContent).toBe(`${activePathname}|${destination.pathname}`) + + await act(async () => { + nextPage.resolve() + await navigationSucceeded + }) + + expect($('p')?.textContent).toBe(`${destination.pathname}|idle`) + }) + + it('leaves links with rmx-document to document navigation', async (t) => { + restoreLocationAfterTest(t) + + let initialUrl = window.location.href + let destination = new URL(initialUrl) + destination.searchParams.set('spa-navigation', 'document-link') + let requests: URL[] = [] + let router: SPAProps['router'] = { + async fetch(url) { + requests.push(url) + return url.href === initialUrl ? ( + + ) : ( + 'Intercepted' + ) + }, + } + let { $, act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let keepTestPageLoaded = (event: NavigateEvent) => { + if (event.destination.url === destination.href) { + event.intercept({ async handler() {} }) + } + } + window.navigation.addEventListener('navigate', keepTestPageLoaded) + t.after(() => window.navigation.removeEventListener('navigate', keepTestPageLoaded)) + + let link = $('a') + if (!link) throw new Error('Expected link') + let didNavigate = false + try { + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + link.click() + await navigationSucceeded + }) + didNavigate = true + + expect(requests).toHaveLength(1) + } finally { + if (didNavigate) { + await act(() => window.navigation.back().finished) + } + } + }) + + it('replaces history for links with rmx-history', async (t) => { + restoreLocationAfterTest(t) + + let initialUrl = window.location.href + let destination = new URL(initialUrl) + destination.searchParams.set('spa-navigation', 'replace-link') + let requests: URL[] = [] + let router: SPAProps['router'] = { + async fetch(url) { + requests.push(url) + return url.href === initialUrl ? ( + + ) : ( + 'Next' + ) + }, + } + let { $, act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let entriesBeforeNavigation = window.navigation.entries().length + let link = $('a') + if (!link) throw new Error('Expected link') + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + link.click() + await navigationSucceeded + }) + + expect(requests.at(-1)).toEqual(destination) + expect(window.navigation.entries()).toHaveLength(entriesBeforeNavigation) + }) + + it('aborts the previous router load when a navigation supersedes it', async (t) => { + restoreLocationAfterTest(t) + + let loads: Array<{ + url: URL + signal: AbortSignal + resolve(node: RemixNode): void + }> = [] + let router: SPAProps['router'] = { + fetch(url, init) { + let signal = init.signal + if (!(signal instanceof AbortSignal)) throw new Error('Expected a navigation signal') + + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + loads.push({ url, signal, resolve }) + }) + }, + } + let { container, act, cleanup } = render() + t.after(cleanup) + await act(() => loads[0]?.resolve('Initial')) + + let firstNavigation = window.navigation.navigate(new URL('/first', window.location.href).href) + let firstFinished = firstNavigation.finished?.catch(() => {}) + if (!firstFinished) throw new Error('Expected first navigation to finish') + await waitFor(() => loads.length === 2) + + let secondNavigation = window.navigation.navigate(new URL('/second', window.location.href).href) + let secondFinished = secondNavigation.finished + if (!secondFinished) throw new Error('Expected second navigation to finish') + await waitFor(() => loads.length === 3) + + expect(loads[1]?.signal.aborted).toBe(true) + await act(async () => { + loads[2]?.resolve('Second') + await Promise.all([firstFinished, secondFinished]) + }) + + expect(container.textContent).toBe('Second') + }) + + it('submits forms and replaces history at the active URL', async (t) => { + restoreLocationAfterTest(t) + + let initialPage = Promise.withResolvers() + let requests: Array<{ url: URL; init: RequestInit }> = [] + let router: SPAProps['router'] = { + async fetch(url, init) { + requests.push({ url, init }) + if (requests.length === 1) return initialPage.promise + return 'Page' + }, + } + let { act, cleanup } = render() + t.after(cleanup) + await act(() => initialPage.resolve('Page')) + + let entriesBeforeSubmission = window.navigation.entries().length + let form = document.createElement('form') + form.method = 'POST' + form.action = window.location.href + let input = document.createElement('input') + input.name = 'name' + input.value = 'Ada' + form.append(input) + document.body.append(form) + t.after(() => form.remove()) + + let navigationSucceeded = new Promise((resolve) => { + window.navigation.addEventListener('navigatesuccess', () => resolve(), { once: true }) + }) + await act(async () => { + form.requestSubmit() + await navigationSucceeded + }) + + let request = requests.at(-1) + expect(request?.url).toEqual(new URL(window.location.href)) + expect(request?.init.method).toBe('POST') + expect(request?.init.signal).toBeInstanceOf(AbortSignal) + expect(request?.init.body).toBeInstanceOf(FormData) + if (!(request?.init.body instanceof FormData)) throw new Error('Expected form data') + expect(Array.from(request.init.body.entries())).toEqual([['name', 'Ada']]) + expect(window.navigation.entries().length).toBe(entriesBeforeSubmission) + }) + + it('leaves forms with rmx-document to document navigation', async (t) => { + restoreLocationAfterTest(t) + + let initialUrl = window.location.href + let destination = new URL(initialUrl) + destination.searchParams.set('spa-navigation', 'document-form') + let requests: URL[] = [] + let router: SPAProps['router'] = { + async fetch(url) { + requests.push(url) + return 'Page' + }, + } + let { act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let form = document.createElement('form') + form.action = destination.href + form.method = 'post' + form.setAttribute('rmx-document', '') + document.body.append(form) + t.after(() => form.remove()) + + let keepTestPageLoaded = (event: NavigateEvent) => { + if (event.destination.url === destination.href) { + event.intercept({ async handler() {} }) + } + } + window.navigation.addEventListener('navigate', keepTestPageLoaded) + t.after(() => window.navigation.removeEventListener('navigate', keepTestPageLoaded)) + + let didNavigate = false + try { + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + form.requestSubmit() + await navigationSucceeded + }) + didNavigate = true + + expect(requests).toHaveLength(1) + } finally { + if (didNavigate) { + await act(() => window.navigation.back().finished) + } + } + }) + + it('replaces history for GET forms with rmx-history', async (t) => { + restoreLocationAfterTest(t) + + let requests: URL[] = [] + let router: SPAProps['router'] = { + async fetch(url) { + requests.push(url) + return 'Page' + }, + } + let { act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let form = document.createElement('form') + form.action = window.location.href + form.method = 'get' + form.setAttribute('rmx-history', 'replace') + let input = document.createElement('input') + input.name = 'query' + input.value = 'remix' + form.append(input) + document.body.append(form) + t.after(() => form.remove()) + + let entriesBeforeSubmission = window.navigation.entries().length + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + form.requestSubmit() + await navigationSucceeded + }) + + expect(requests.at(-1)?.searchParams.get('query')).toBe('remix') + expect(window.navigation.entries()).toHaveLength(entriesBeforeSubmission) + }) + + it('replaces history for non-GET forms with rmx-history', async (t) => { + restoreLocationAfterTest(t) + + let destination = new URL(window.location.href) + destination.searchParams.set('spa-navigation', 'replace-form') + let requests: Array<{ url: URL; init: RequestInit }> = [] + let router: SPAProps['router'] = { + async fetch(url, init) { + requests.push({ url, init }) + return 'Page' + }, + } + let { act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let form = document.createElement('form') + form.action = destination.href + form.method = 'post' + form.setAttribute('rmx-history', 'replace') + let input = document.createElement('input') + input.name = 'name' + input.value = 'Ada' + form.append(input) + document.body.append(form) + t.after(() => form.remove()) + + let entriesBeforeSubmission = window.navigation.entries().length + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + form.requestSubmit() + await navigationSucceeded + }) + + let request = requests.at(-1) + expect(request?.url).toEqual(destination) + expect(request?.init.method).toBe('POST') + expect(request?.init.body).toBeInstanceOf(FormData) + expect(window.navigation.entries()).toHaveLength(entriesBeforeSubmission) + }) + + it('pushes same-location non-GET forms when rmx-history is push', async (t) => { + restoreLocationAfterTest(t) + + let router: SPAProps['router'] = { + async fetch() { + return 'Page' + }, + } + let { act, cleanup } = render() + t.after(cleanup) + await act(() => {}) + + let form = document.createElement('form') + form.action = window.location.href + form.method = 'post' + form.setAttribute('rmx-history', 'push') + document.body.append(form) + t.after(() => form.remove()) + + let entryBeforeSubmission = window.navigation.currentEntry + if (!entryBeforeSubmission) throw new Error('Expected current navigation entry') + let didNavigate = false + try { + let navigationSucceeded = waitForNavigationSuccess() + await act(async () => { + form.requestSubmit() + await navigationSucceeded + }) + didNavigate = true + + expect(window.navigation.currentEntry?.index).toBe(entryBeforeSubmission.index + 1) + } finally { + if (didNavigate) { + await act(() => window.navigation.back().finished) + } + } + }) +}) diff --git a/packages/ui/src/spa/index.ts b/packages/ui/src/spa/index.ts new file mode 100644 index 00000000000..bd8677d5914 --- /dev/null +++ b/packages/ui/src/spa/index.ts @@ -0,0 +1,209 @@ +import { addEventListeners, type Handle, type RemixNode } from '@remix-run/ui' +import { createFormNavigationResolver, type FormSubmission } from '../runtime/form-navigation.ts' +import { + getLinkNavigationElement, + getReplaceHistory, + interceptNavigation, + type NavigationReplacement, +} from '../runtime/navigation-event.ts' + +/** + * Options accepted by the {@link SPA} component and {@link createSPA} utility. + */ +export interface SPAProps { + /** Content rendered until the initial URL resolves. */ + fallback: RemixNode + /** Router that resolves browser URLs to renderable UI. */ + router: { + /** + * Resolves a URL to renderable UI. + * + * @param url Destination URL. + * @param init Request options, including the navigation signal and submitted form data. + * @returns The UI for the destination. + */ + fetch(url: URL, init: RequestInit): Promise + } +} + +/** + * Navigation state provided to descendants of the {@link SPA} component. + */ +export interface SPAContext { + /** URL represented by the currently rendered UI. */ + readonly active: URL + /** URL currently being loaded, or `undefined` when navigation is idle. */ + readonly pending: URL | undefined +} + +/** + * Live SPA state used by custom SPA component implementations. + */ +export interface SPAMeta { + /** Stable navigation state suitable for a component context. */ + readonly context: SPAContext + /** Currently rendered router output, or the fallback during the initial load. */ + readonly node: RemixNode +} + +interface FormSubmissionNavigationInfo { + type: typeof formSubmissionNavigationInfoType + getSubmission(): Promise +} + +const formSubmissionNavigationInfoType = 'spa-form-submission' + +/** + * Renders browser URLs through a URL-to-node router and intercepts same-origin navigations. + * + * Form submissions use their native method. Non-GET submissions include their `FormData` and + * replace the current history entry when submitted to the active URL. Submissions to a different + * URL push a new entry. Navigation history entries do not retain submitted `FormData`, so history + * traversals revisit form destinations with `GET` requests. Links and forms can use + * `rmx-document` to bypass SPA interception or `rmx-history` to control history behavior. + * + * @param handle Component handle containing the router and initial fallback. + * @returns A render function for the active router output. + */ +export function SPA(handle: Handle): () => RemixNode { + let spa = createSPA(handle, handle.props) + handle.context.set(spa.context) + return () => spa.node +} + +/** + * Creates SPA navigation state for use in a component setup scope. + * + * @param handle Component handle that owns the SPA navigation lifecycle. + * @param options Router and fallback UI used to resolve browser URLs. + * @returns Live SPA navigation state and rendered UI. + */ +export function createSPA(handle: Handle, options: SPAProps): SPAMeta { + let node = options.fallback + let currentController: AbortController | undefined + let active = new URL(window.location.href) + let pending: URL | undefined + let resolveFormNavigation: ReturnType + let context: SPAContext = { + get active() { + return active + }, + get pending() { + return pending + }, + } + + function onNavigate(navigateEvent: NavigateEvent): void { + if (!navigateEvent.canIntercept) return + if (navigateEvent.hashChange) return + if (navigateEvent.downloadRequest) return + + let destination = new URL(navigateEvent.destination.url) + if (destination.origin !== window.location.origin) return + + let replayedSubmission = isFormSubmissionNavigationInfo(navigateEvent.info) + ? navigateEvent.info + : undefined + let linkElement = replayedSubmission ? undefined : getLinkNavigationElement(navigateEvent) + let formNavigation = replayedSubmission ? undefined : resolveFormNavigation(navigateEvent) + if (linkElement?.hasAttribute('rmx-document')) return + if (formNavigation?.hasAttribute('rmx-document')) return + + let getSubmission = replayedSubmission?.getSubmission ?? formNavigation?.getSubmission + let replaceHistoryByDefault = + formNavigation?.getSubmission !== undefined && destination.href === active.href + let replaceHistory = + replayedSubmission == null && + getReplaceHistory( + linkElement?.getAttribute('rmx-history') ?? + formNavigation?.getAttribute('rmx-history') ?? + null, + replaceHistoryByDefault, + ) + + let handler = async () => { + let submission = await getSubmission?.() + if (navigateEvent.signal.aborted) return + await updatePage(destination, navigateEvent.signal, submission) + } + + let replacement: NavigationReplacement | undefined + if (replaceHistory) { + replacement = getSubmission + ? { + type: 'form-submission', + info: { + type: formSubmissionNavigationInfoType, + getSubmission, + } satisfies FormSubmissionNavigationInfo, + } + : { type: 'navigation' } + } + + interceptNavigation(navigateEvent, { handler, replacement }) + } + + async function updatePage( + url: URL, + navigationSignal: AbortSignal, + submission: FormSubmission | undefined, + ): Promise { + currentController?.abort() + + let controller = new AbortController() + let signal = AbortSignal.any([handle.signal, navigationSignal, controller.signal]) + currentController = controller + pending = url + handle.update() + + try { + let init: RequestInit = { signal } + if (submission) { + init.method = submission.method.toUpperCase() + if (init.method !== 'GET' && init.method !== 'HEAD') { + init.body = submission.formData + } + } + + let nextNode = await options.router.fetch(url, init) + if (signal.aborted) return + + node = nextNode + active = url + } catch (error) { + if (!signal.aborted) throw error + } finally { + if (currentController === controller) { + currentController = undefined + pending = undefined + handle.update() + } + } + } + + handle.queueTask(() => { + resolveFormNavigation = createFormNavigationResolver(handle.signal) + addEventListeners(window.navigation, handle.signal, { navigate: onNavigate }) + void updatePage(new URL(window.location.href), handle.signal, undefined) + }) + + return { + get context() { + return context + }, + get node() { + return node + }, + } +} + +function isFormSubmissionNavigationInfo(value: unknown): value is FormSubmissionNavigationInfo { + return ( + typeof value === 'object' && + value != null && + 'type' in value && + value.type === formSubmissionNavigationInfoType && + 'getSubmission' in value && + typeof value.getSubmission === 'function' + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2fb69afb92..a63a4e61800 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,28 @@ importers: specifier: 'catalog:' version: 7.0.2 + demos/spa: + dependencies: + remix: + specifier: workspace:* + version: link:../../packages/remix + devDependencies: + '@types/dom-navigation': + specifier: ^1.0.7 + version: 1.0.7 + '@types/node': + specifier: 'catalog:' + version: 24.10.4 + playwright: + specifier: 'catalog:' + version: 1.60.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 7.3.3 + version: 7.3.3(@types/node@24.10.4)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) + demos/sse: dependencies: remix: