Skip to content

Commit 2f2c826

Browse files
committed
Add a SPA demo
Assisted-By: devx/755516d2-c45b-45bf-b52b-d964afb579bb
1 parent 3ff97d1 commit 2f2c826

12 files changed

Lines changed: 473 additions & 0 deletions

File tree

demos/spa/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPA Demo
2+
3+
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`.
4+
5+
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.
6+
7+
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()`.
8+
9+
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.
10+
11+
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.
12+
13+
## Run It
14+
15+
```sh
16+
pnpm -C demos/spa dev
17+
```
18+
19+
Then open `http://localhost:44100`.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { createController } from 'remix/router'
2+
import { css, type Handle } from 'remix/ui'
3+
4+
import { routes } from '../routes.ts'
5+
6+
export default createController(routes, {
7+
actions: {
8+
async home(context) {
9+
await sleep(1000, context.request.signal)
10+
return <HomePage />
11+
},
12+
13+
async about(context) {
14+
await sleep(1000, context.request.signal)
15+
return <AboutPage />
16+
},
17+
18+
async greet(context) {
19+
let name = ''
20+
if (context.request.method === 'POST') {
21+
let formData = await context.request.formData()
22+
let value = formData.get('name')
23+
name = typeof value === 'string' ? value.trim() : ''
24+
}
25+
26+
await sleep(1000, context.request.signal)
27+
return <GreetingPage name={name || 'friend'} />
28+
},
29+
},
30+
})
31+
32+
function HomePage() {
33+
return () => (
34+
<article>
35+
<p mix={eyebrowStyle}>Home</p>
36+
<h1 mix={titleStyle}>A client-only Remix app</h1>
37+
<p mix={bodyStyle}>
38+
This page came directly from a fetch router handler. No HTTP request or response was
39+
involved.
40+
</p>
41+
<form method="POST" action={routes.greet.href()} mix={formStyle}>
42+
<label htmlFor="name" mix={labelStyle}>
43+
What should we call you?
44+
</label>
45+
<div mix={formControlsStyle}>
46+
<input id="name" name="name" autoComplete="name" required mix={inputStyle} />
47+
<button type="submit" mix={buttonStyle}>
48+
Submit
49+
</button>
50+
</div>
51+
</form>
52+
</article>
53+
)
54+
}
55+
56+
function AboutPage() {
57+
return () => (
58+
<article>
59+
<p mix={eyebrowStyle}>About</p>
60+
<h1 mix={titleStyle}>URLs in, rendered UI out</h1>
61+
<p mix={bodyStyle}>
62+
Each route waits briefly before returning a <code>RemixNode</code>, so the loading and
63+
cancellation behavior is easy to see.
64+
</p>
65+
</article>
66+
)
67+
}
68+
69+
function GreetingPage(handle: Handle<{ name: string }>) {
70+
return () => (
71+
<article>
72+
<p mix={eyebrowStyle}>Form submitted</p>
73+
<h1 mix={titleStyle}>Hello, {handle.props.name}!</h1>
74+
<p mix={bodyStyle}>
75+
POST submissions expose the Navigation API's form data through{' '}
76+
<code>context.request.formData()</code> without making an HTTP request. History traversals
77+
return here with GET because navigation entries do not retain <code>FormData</code>.
78+
</p>
79+
<form method="POST" action={routes.greet.href()} mix={formStyle}>
80+
<label htmlFor="next-name" mix={labelStyle}>
81+
Try another name
82+
</label>
83+
<div mix={formControlsStyle}>
84+
<input id="next-name" name="name" autoComplete="name" required mix={inputStyle} />
85+
<button type="submit" mix={buttonStyle}>
86+
Submit again
87+
</button>
88+
</div>
89+
</form>
90+
<p mix={bodyStyle}>
91+
Because this form submits to the current URL, it replaces the current history entry. The{' '}
92+
<a href={routes.home.href()} mix={linkStyle}>
93+
first submission
94+
</a>{' '}
95+
pushed a new entry because it navigated here from another URL.
96+
</p>
97+
</article>
98+
)
99+
}
100+
101+
export function NotFoundPage() {
102+
return () => (
103+
<article>
104+
<p mix={eyebrowStyle}>404</p>
105+
<h1 mix={titleStyle}>Page not found</h1>
106+
<p mix={bodyStyle}>
107+
Try going back to the{' '}
108+
<a href={routes.home.href()} mix={linkStyle}>
109+
home page
110+
</a>
111+
.
112+
</p>
113+
</article>
114+
)
115+
}
116+
117+
function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
118+
return new Promise((resolve, reject) => {
119+
if (signal.aborted) {
120+
reject(signal.reason)
121+
return
122+
}
123+
124+
let timeout = setTimeout(() => {
125+
signal.removeEventListener('abort', handleAbort)
126+
resolve()
127+
}, milliseconds)
128+
129+
function handleAbort() {
130+
clearTimeout(timeout)
131+
reject(signal.reason)
132+
}
133+
134+
signal.addEventListener('abort', handleAbort, { once: true })
135+
})
136+
}
137+
138+
const eyebrowStyle = css({
139+
margin: '0 0 0.5rem',
140+
color: '#6a48d7',
141+
fontSize: '0.75rem',
142+
fontWeight: 700,
143+
letterSpacing: '0.12em',
144+
textTransform: 'uppercase',
145+
})
146+
147+
const titleStyle = css({
148+
margin: 0,
149+
fontSize: 'clamp(2rem, 7vw, 3.5rem)',
150+
lineHeight: 1.05,
151+
})
152+
153+
const bodyStyle = css({
154+
maxWidth: '38rem',
155+
margin: '1.5rem 0 0',
156+
color: '#5c5965',
157+
fontSize: '1.125rem',
158+
lineHeight: 1.7,
159+
})
160+
161+
const formStyle = css({
162+
display: 'grid',
163+
gap: '0.75rem',
164+
maxWidth: '30rem',
165+
marginTop: '2rem',
166+
})
167+
168+
const labelStyle = css({
169+
fontWeight: 700,
170+
})
171+
172+
const formControlsStyle = css({
173+
display: 'flex',
174+
gap: '0.75rem',
175+
})
176+
177+
const inputStyle = css({
178+
minWidth: 0,
179+
flex: 1,
180+
border: '1px solid #bcb4d4',
181+
borderRadius: '0.6rem',
182+
padding: '0.7rem 0.8rem',
183+
font: 'inherit',
184+
})
185+
186+
const buttonStyle = css({
187+
border: 0,
188+
borderRadius: '0.6rem',
189+
padding: '0.7rem 1rem',
190+
color: 'white',
191+
backgroundColor: '#5b36d6',
192+
font: 'inherit',
193+
fontWeight: 700,
194+
cursor: 'pointer',
195+
})
196+
197+
const linkStyle = css({
198+
color: '#5b36d6',
199+
})

demos/spa/app/main.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createRoot } from 'remix/ui'
2+
import { SPA } from 'remix/ui/spa'
3+
4+
import { router } from './router.tsx'
5+
import { Fallback } from './ui/layout.tsx'
6+
7+
const root = createRoot(document.getElementById('app')!)
8+
9+
root.addEventListener('error', (event) => {
10+
console.error('Remix UI root failed:', event.error)
11+
})
12+
13+
root.render(<SPA router={router} fallback={<Fallback />} />)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Middleware } from 'remix/router'
2+
3+
import { Layout } from '../ui/layout.tsx'
4+
5+
export function render(): Middleware {
6+
return async (_context, next) => {
7+
let node = await next()
8+
return <Layout>{node}</Layout>
9+
}
10+
}

demos/spa/app/router.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createRouter } from 'remix/router'
2+
import type { RemixNode } from 'remix/ui'
3+
4+
import rootController, { NotFoundPage } from './actions/controller.tsx'
5+
import { render } from './middleware/render.tsx'
6+
import { routes } from './routes.ts'
7+
8+
declare module 'remix/router' {
9+
interface RouterTypes {
10+
output: RemixNode
11+
}
12+
}
13+
14+
export const router = createRouter({
15+
middleware: [render()],
16+
defaultHandler: () => <NotFoundPage />,
17+
})
18+
19+
router.map(routes, rootController)

demos/spa/app/routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { get, route } from 'remix/routes'
2+
3+
export const routes = route({
4+
home: get('/'),
5+
about: get('/about'),
6+
greet: '/greet',
7+
})

demos/spa/app/ui/layout.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { css, type Handle, type RemixNode } from 'remix/ui'
2+
import { SPA } from 'remix/ui/spa'
3+
4+
import { routes } from '../routes.ts'
5+
6+
interface LayoutProps {
7+
children?: RemixNode
8+
}
9+
10+
export function Fallback() {
11+
return () => (
12+
<Layout>
13+
<LoadingPage />
14+
</Layout>
15+
)
16+
}
17+
18+
export function Layout(handle: Handle<LayoutProps>) {
19+
let router = handle.context.get(SPA)
20+
21+
return () => {
22+
let isPending = router.pending != null
23+
let content = isPending ? <LoadingPage /> : handle.props.children
24+
25+
return (
26+
<div mix={appShellStyle}>
27+
<div mix={contentStyle}>
28+
<header mix={headerStyle}>
29+
<a href={routes.home.href()} mix={brandStyle}>
30+
Remix SPA
31+
</a>
32+
<nav aria-label="Main navigation" mix={navStyle}>
33+
<a
34+
href={routes.home.href()}
35+
aria-current={router.active.pathname === routes.home.href() ? 'page' : undefined}
36+
mix={navLinkStyle}
37+
>
38+
Home
39+
</a>
40+
<a
41+
href={routes.about.href()}
42+
aria-current={router.active.pathname === routes.about.href() ? 'page' : undefined}
43+
mix={navLinkStyle}
44+
>
45+
About
46+
</a>
47+
</nav>
48+
</header>
49+
<main aria-busy={isPending} mix={mainStyle}>
50+
{content}
51+
</main>
52+
</div>
53+
</div>
54+
)
55+
}
56+
}
57+
58+
export function LoadingPage() {
59+
return () => (
60+
<div role="status" mix={loadingStyle}>
61+
Loading…
62+
</div>
63+
)
64+
}
65+
66+
const appShellStyle = css({
67+
position: 'fixed',
68+
inset: 0,
69+
minWidth: 320,
70+
overflow: 'auto',
71+
color: '#202124',
72+
backgroundColor: '#f7f5ff',
73+
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
74+
fontSynthesis: 'none',
75+
'& *': {
76+
boxSizing: 'border-box',
77+
},
78+
})
79+
80+
const contentStyle = css({
81+
width: 'min(100% - 2rem, 48rem)',
82+
margin: '0 auto',
83+
})
84+
85+
const headerStyle = css({
86+
display: 'flex',
87+
alignItems: 'center',
88+
justifyContent: 'space-between',
89+
padding: '1.5rem 0',
90+
})
91+
92+
const brandStyle = css({
93+
color: 'inherit',
94+
fontSize: '1.125rem',
95+
fontWeight: 700,
96+
textDecoration: 'none',
97+
})
98+
99+
const navStyle = css({
100+
display: 'flex',
101+
gap: '0.5rem',
102+
})
103+
104+
const navLinkStyle = css({
105+
borderRadius: 999,
106+
padding: '0.5rem 0.75rem',
107+
color: '#5b36d6',
108+
textDecoration: 'none',
109+
'&:hover, &[aria-current="page"]': {
110+
backgroundColor: '#e7e0ff',
111+
},
112+
})
113+
114+
const mainStyle = css({
115+
minHeight: '18rem',
116+
border: '1px solid #ded8ef',
117+
borderRadius: '1rem',
118+
backgroundColor: 'white',
119+
boxShadow: '0 1rem 3rem rgb(64 44 120 / 10%)',
120+
padding: 'clamp(2rem, 8vw, 5rem)',
121+
})
122+
123+
const loadingStyle = css({
124+
color: '#6a48d7',
125+
fontSize: '1.125rem',
126+
})

0 commit comments

Comments
 (0)