-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add SPA routing with custom fetch router outputs #11629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
brophdawg11
wants to merge
3
commits into
brophdawg11/rmx-replace-navigation
from
brophdawg11/codex-spa-router
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 /> | ||
| }, | ||
|
|
||
| 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}> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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', | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 />} />) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the important part to enable returning |
||
|
|
||
| export const router = createRouter({ | ||
| middleware: [render()], | ||
| defaultHandler: () => <NotFoundPage />, | ||
| }) | ||
|
|
||
| router.map(routes, rootController) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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