Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions demos/spa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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`.
202 changes: 202 additions & 0 deletions demos/spa/app/actions/controller.tsx
Original file line number Diff line number Diff line change
@@ -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 <HomePage />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

actions now return Remix nodes

},

async about(context) {
await sleep(1000, context.request.signal)
return <AboutPage />
},

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 <GreetingPage isSubmission={isSubmission} name={name} />
},
},
})

function HomePage() {
return () => (
<article>
<p mix={eyebrowStyle}>Home</p>
<h1 mix={titleStyle}>A client-only Remix app</h1>
<p mix={bodyStyle}>
This page came directly from a fetch router handler. No HTTP request or response was
involved.
</p>
<form method="POST" action={routes.greet.href()} mix={formStyle}>
<label htmlFor="name" mix={labelStyle}>
What should we call you?
</label>
<div mix={formControlsStyle}>
<input id="name" name="name" autoComplete="name" required mix={inputStyle} />
<button type="submit" mix={buttonStyle}>
Submit
</button>
</div>
</form>
</article>
)
}

function AboutPage() {
return () => (
<article>
<p mix={eyebrowStyle}>About</p>
<h1 mix={titleStyle}>URLs in, rendered UI out</h1>
<p mix={bodyStyle}>
Each route waits briefly before returning a <code>RemixNode</code>, so the loading and
cancellation behavior is easy to see.
</p>
</article>
)
}

function GreetingPage(handle: Handle<{ isSubmission: boolean; name: string }>) {
return () => (
<article>
{handle.props.isSubmission ? <p mix={eyebrowStyle}>Form submitted</p> : null}
<h1 mix={titleStyle}>Hello, {handle.props.name}!</h1>
<p mix={bodyStyle}>
POST submissions expose the Navigation API's form data through{' '}
<code>context.request.formData()</code> without making an HTTP request. History traversals
return here with GET because navigation entries do not retain <code>FormData</code>.
</p>
<form method="POST" action={routes.greet.href()} mix={formStyle}>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Form submissions are also handled by the SPA router component

<label htmlFor="next-name" mix={labelStyle}>
Try another name
</label>
<div mix={formControlsStyle}>
<input id="next-name" name="name" autoComplete="name" required mix={inputStyle} />
<button type="submit" mix={buttonStyle}>
Submit again
</button>
</div>
</form>
<p mix={bodyStyle}>
Because this form submits to the current URL, it replaces the current history entry. The{' '}
<a href={routes.home.href()} mix={linkStyle}>
first submission
</a>{' '}
pushed a new entry because it navigated here from another URL.
</p>
</article>
)
}

export function NotFoundPage() {
return () => (
<article>
<p mix={eyebrowStyle}>404</p>
<h1 mix={titleStyle}>Page not found</h1>
<p mix={bodyStyle}>
Try going back to the{' '}
<a href={routes.home.href()} mix={linkStyle}>
home page
</a>
.
</p>
</article>
)
}

function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
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',
})
13 changes: 13 additions & 0 deletions demos/spa/app/main.tsx
Original file line number Diff line number Diff line change
@@ -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(<SPA router={router} fallback={<Fallback />} />)
10 changes: 10 additions & 0 deletions demos/spa/app/middleware/render.tsx
Original file line number Diff line number Diff line change
@@ -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 <Layout>{node}</Layout>
}
}
19 changes: 19 additions & 0 deletions demos/spa/app/router.tsx
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment on lines +8 to +12

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the important part to enable returning RemixNode's from the router


export const router = createRouter({
middleware: [render()],
defaultHandler: () => <NotFoundPage />,
})

router.map(routes, rootController)
7 changes: 7 additions & 0 deletions demos/spa/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { get, route } from 'remix/routes'

export const routes = route({
home: get('/'),
about: get('/about'),
greet: '/greet',
})
Loading
Loading