Skip to content

Commit 9cc265e

Browse files
committed
Support navigation attributes in SPA
1 parent 4fd3be6 commit 9cc265e

9 files changed

Lines changed: 414 additions & 121 deletions

File tree

demos/spa/app/actions/controller.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@ export default createController(routes, {
1616
},
1717

1818
async greet(context) {
19-
let name = ''
20-
if (context.request.method === 'POST') {
19+
let isSubmission = context.request.method === 'POST'
20+
let name = 'friend'
21+
if (isSubmission) {
2122
let formData = await context.request.formData()
2223
let value = formData.get('name')
23-
name = typeof value === 'string' ? value.trim() : ''
24+
if (typeof value === 'string' && value.trim() !== '') {
25+
name = value.trim()
26+
}
2427
}
2528

2629
await sleep(1000, context.request.signal)
27-
return <GreetingPage name={name || 'friend'} />
30+
return <GreetingPage isSubmission={isSubmission} name={name} />
2831
},
2932
},
3033
})
@@ -66,10 +69,10 @@ function AboutPage() {
6669
)
6770
}
6871

69-
function GreetingPage(handle: Handle<{ name: string }>) {
72+
function GreetingPage(handle: Handle<{ isSubmission: boolean; name: string }>) {
7073
return () => (
7174
<article>
72-
<p mix={eyebrowStyle}>Form submitted</p>
75+
{handle.props.isSubmission ? <p mix={eyebrowStyle}>Form submitted</p> : null}
7376
<h1 mix={titleStyle}>Hello, {handle.props.name}!</h1>
7477
<p mix={bodyStyle}>
7578
POST submissions expose the Navigation API's form data through{' '}

demos/spa/app/ui/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export function Layout(handle: Handle<LayoutProps>) {
4444
>
4545
About
4646
</a>
47+
<a
48+
href={routes.greet.href()}
49+
aria-current={router.active.pathname === routes.greet.href() ? 'page' : undefined}
50+
mix={navLinkStyle}
51+
>
52+
Greet
53+
</a>
4754
</nav>
4855
</header>
4956
<main aria-busy={isPending} mix={mainStyle}>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Export an `SPA` component and `createSPA` setup utility from `remix/ui/spa` that render router outputs during client-side navigation.
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.

packages/ui/.changes/minor.spa.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
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, and dispatch intercepted form submissions with their `FormData`.
1+
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.
22

33
```tsx
44
import { createRouter } from 'remix/router'
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
type SourceElementNavigateEvent = NavigateEvent & {
2+
sourceElement?: Element | null
3+
}
4+
5+
interface NavigationPrecommitControllerLike {
6+
redirect(url: string, options: { history: 'replace' }): void
7+
}
8+
9+
interface NavigationInterceptOptionsWithPrecommit extends NavigationInterceptOptions {
10+
precommitHandler(controller: NavigationPrecommitControllerLike): void
11+
}
12+
13+
export type NavigationReplacement =
14+
| {
15+
type: 'navigation'
16+
state?: unknown
17+
}
18+
| {
19+
type: 'form-submission'
20+
info: unknown
21+
state?: unknown
22+
}
23+
24+
export function getLinkNavigationElement(event: NavigateEvent): Element | undefined {
25+
let sourceElement = (event as SourceElementNavigateEvent).sourceElement
26+
if (!(sourceElement instanceof Element)) return
27+
28+
let linkElement = sourceElement.closest('a, area')
29+
return linkElement instanceof Element ? linkElement : undefined
30+
}
31+
32+
export function getReplaceHistory(value: string | null, defaultValue: boolean): boolean {
33+
if (value === 'replace') return true
34+
if (value === 'push') return false
35+
return defaultValue
36+
}
37+
38+
export function interceptNavigation(
39+
event: NavigateEvent,
40+
options: {
41+
handler(): Promise<void>
42+
replacement: NavigationReplacement | undefined
43+
},
44+
): void {
45+
let replacement = options.replacement
46+
if (replacement == null) {
47+
event.intercept({ handler: options.handler })
48+
return
49+
}
50+
51+
if (
52+
replacement.type === 'form-submission' &&
53+
typeof Reflect.get(window, 'NavigationPrecommitController') === 'function'
54+
) {
55+
let interceptOptions: NavigationInterceptOptionsWithPrecommit = {
56+
handler: options.handler,
57+
precommitHandler(controller) {
58+
controller.redirect(event.destination.url, { history: 'replace' })
59+
},
60+
}
61+
event.intercept(interceptOptions)
62+
return
63+
}
64+
65+
if (event.cancelable) {
66+
event.preventDefault()
67+
window.navigation.navigate(event.destination.url, {
68+
history: 'replace',
69+
info: replacement.type === 'form-submission' ? replacement.info : undefined,
70+
state: replacement.state,
71+
})
72+
return
73+
}
74+
75+
event.intercept({ handler: options.handler })
76+
}

packages/ui/src/runtime/navigation.ts

Lines changed: 17 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { getTopFrame, getNamedFrame } from './run.ts'
22
import { createFormNavigationResolver, type FormSubmission } from './form-navigation.ts'
3-
4-
interface NavigationPrecommitControllerLike {
5-
redirect(url: string, options: { history: 'replace' }): void
6-
}
7-
8-
interface NavigationInterceptOptionsWithPrecommit extends NavigationInterceptOptions {
9-
handler(): Promise<void>
10-
precommitHandler(controller: NavigationPrecommitControllerLike): void
11-
}
3+
import {
4+
getLinkNavigationElement,
5+
getReplaceHistory,
6+
interceptNavigation,
7+
type NavigationReplacement,
8+
} from './navigation-event.ts'
129

1310
type NavigationState = {
1411
target: string | undefined
@@ -17,10 +14,6 @@ type NavigationState = {
1714
$rmx: true
1815
}
1916

20-
type SourceElementNavigateEvent = NavigateEvent & {
21-
sourceElement?: Element | null
22-
}
23-
2417
type RuntimeNavigation = {
2518
state: NavigationState
2619
getSubmission?: () => Promise<FormSubmission>
@@ -155,50 +148,22 @@ export function startNavigationListenerImpl(
155148
}
156149
}
157150

158-
if (runtimeNavigation.getSubmission) {
159-
// <form method="post"> navigations
160-
if (runtimeNavigation.replaceHistory && replayedSubmission == null) {
161-
let supportsPrecommit =
162-
typeof Reflect.get(window, 'NavigationPrecommitController') === 'function'
163-
164-
// Modern browsers allow you to update the in-flight navigation entry before it's committed
165-
if (supportsPrecommit) {
166-
let interceptOptions: NavigationInterceptOptionsWithPrecommit = {
167-
handler,
168-
precommitHandler(controller) {
169-
controller.redirect(event.destination.url, { history: 'replace' })
170-
},
171-
}
172-
event.intercept(interceptOptions)
173-
return
174-
}
175-
176-
// Safari doesn't support precommit as of Aug 2026, so we do a full replacement navigation
177-
if (event.cancelable) {
178-
event.preventDefault()
179-
window.navigation.navigate(event.destination.url, {
180-
history: 'replace',
151+
let replacement: NavigationReplacement | undefined
152+
if (runtimeNavigation.replaceHistory && replayedSubmission == null) {
153+
replacement = runtimeNavigation.getSubmission
154+
? {
155+
type: 'form-submission',
181156
state,
182157
info: {
183158
type: formSubmissionNavigationInfoType,
184159
state,
185160
getSubmission: runtimeNavigation.getSubmission,
186161
} satisfies FormSubmissionNavigationInfo,
187-
})
188-
return
189-
}
190-
}
191-
192-
event.intercept({ handler })
193-
} else {
194-
// <a>/<form method="get"> navigations
195-
if (runtimeNavigation.replaceHistory && event.cancelable) {
196-
event.preventDefault()
197-
navigation.navigate(event.destination.url, { history: 'replace', state })
198-
} else {
199-
event.intercept({ handler })
200-
}
162+
}
163+
: { type: 'navigation', state }
201164
}
165+
166+
interceptNavigation(event, { handler, replacement })
202167
},
203168
{ signal },
204169
)
@@ -275,12 +240,8 @@ function getSourceElementNavigation(
275240
event: NavigateEvent,
276241
resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
277242
): RuntimeNavigation | undefined {
278-
let sourceEvent = event as SourceElementNavigateEvent
279-
let sourceElement = sourceEvent.sourceElement
280-
if (!(sourceElement instanceof Element)) return
281-
282-
let linkElement = sourceElement.closest('a, area')
283-
if (linkElement instanceof Element) {
243+
let linkElement = getLinkNavigationElement(event)
244+
if (linkElement) {
284245
if (linkElement.hasAttribute('rmx-document')) return
285246
if (linkElement.hasAttribute('download')) return
286247

@@ -316,9 +277,3 @@ function getSourceElementNavigation(
316277
getSubmission: formNavigation.getSubmission,
317278
}
318279
}
319-
320-
function getReplaceHistory(value: string | null, defaultValue: boolean): boolean {
321-
if (value === 'replace') return true
322-
if (value === 'push') return false
323-
return defaultValue
324-
}

packages/ui/src/spa/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ root.render(<SPA router={router} fallback="Loading…" />)
2222

2323
`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.
2424

25+
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.
26+
2527
Use `createSPA` in the setup scope of a custom top-level component when it needs to compose the rendered node or navigation state:
2628

2729
```tsx

0 commit comments

Comments
 (0)