-
Notifications
You must be signed in to change notification settings - Fork 45
Add client side navigation #530
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
--- | ||
title: Client Side Navigation (Single Page Apps) | ||
description: Implement client side navigation in your RedwoodSDK project | ||
--- | ||
|
||
## What is Client Side Navigation? | ||
|
||
Client-side navigation is a technique that allows users to move between pages without a full-page reload. Instead of the browser reloading the entire HTML document, the JavaScript runtime intercepts navigation events (like link clicks), fetches the next page's content (usually as JavaScript modules or RSC payload), and updates the current view. | ||
|
||
This approach is commonly referred to as a Single Page App (SPA). In RedwoodSDK, you get SPA-like navigation with server-fetched React Server Components (RSC), so it's fast and dynamic, but still uses the server for rendering. | ||
|
||
```tsx title="src/client.tsx" ins="initClientNavigation()" | ||
import { initClient, initClientNavigation } from "rwsdk/client"; | ||
|
||
initClient(); | ||
initClientNavigation(); | ||
``` | ||
|
||
Once this is initialized, internal `<a href="/some-path">` links will no longer trigger full-page reloads. Instead, the SDK will: | ||
|
||
1. Intercept the link click, | ||
2. Push the new URL to the browser's history, | ||
3. Fetch the new page's RSC payload from the server, | ||
4. And hydrate it on the client. | ||
|
||
RedwoodSDK keeps everything minimal and transparent. No magic routing system. No nested router contexts. You get the benefits of a modern SPA without giving up control. | ||
|
||
## Transitions and View Animations | ||
|
||
Client-side navigation enables you to animate between pages without jank. Pair it with View Transitions in React 19 to create seamless visual transitions. | ||
|
||
## Caveats | ||
|
||
No routing system is included: RedwoodSDK doesn't provide a client-side router. You can layer your own state management or page transitions as needed. | ||
|
||
Only internal links are intercepted: RedwoodSDK will only handle links pointing to the same origin. External links (https://example.com) or those with `target="\_blank"` behave normally. | ||
|
||
Middleware still runs: Every navigation hits your server again — so auth checks, headers, and streaming behavior remain intact. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
--- | ||
title: sdk/client | ||
description: Client Side Functions | ||
next: false | ||
--- | ||
|
||
The `rwsdk/client` module provides a set of functions for client-side operations. | ||
|
||
## `initClient` | ||
|
||
The `initClient` function is used to initialize the React Client. This hydrates the RSC flight payload that's add at the bottom of the page. This makes the page interactive. | ||
|
||
## `initClientNavigation` | ||
|
||
The `initClientNavigation` function is used to initialize the client side navigation. An event handler is assocated to clicking the document. If the clicked element contains a link, href, and the href is a relative path, the event handler will be triggered. This will then fetch the RSC payload for the new page, and hydrate it on the client. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
export function initClientNavigation( | ||
opts: { | ||
onNavigate: () => void; | ||
} = { | ||
onNavigate: async function onNavigate() { | ||
// @ts-expect-error | ||
await globalThis.__rsc_callServer(); | ||
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. Incredible how simple this is! 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. Yeah, it's pretty incredible. |
||
}, | ||
}, | ||
) { | ||
// Intercept all anchor tag clicks | ||
document.addEventListener( | ||
"click", | ||
async function handleClickEvent(event: MouseEvent) { | ||
// should this only work for left click? | ||
if (event.button !== 0) { | ||
return; | ||
} | ||
|
||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { | ||
return; | ||
} | ||
|
||
const target = event.target as HTMLElement; | ||
const link = target.closest("a"); | ||
|
||
if (!link) { | ||
return; | ||
} | ||
|
||
const href = link.getAttribute("href"); | ||
if (!href) { | ||
return; | ||
} | ||
|
||
// Skip if target="_blank" or similar | ||
if (link.target && link.target !== "_self") { | ||
return; | ||
} | ||
|
||
// Skip if download attribute | ||
if (link.hasAttribute("download")) { | ||
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. Should we document this? 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. My intuition says that it's such a little footnote that it doesn't really matter. It's the expected behavior. 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. Ah, woops, I didn't know its an actual standard attribute: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#download :D But I also don't know how I was thinking it could be non-standard 🤦 |
||
return; | ||
} | ||
|
||
// Prevent default navigation | ||
event.preventDefault(); | ||
|
||
// push this to the history stack. | ||
window.history.pushState( | ||
{ path: href }, | ||
"", | ||
window.location.origin + href, | ||
); | ||
|
||
await opts.onNavigate(); | ||
}, | ||
true, | ||
); | ||
|
||
// Handle browser back/forward buttons | ||
window.addEventListener("popstate", async function handlePopState() { | ||
await opts.onNavigate(); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from "../client"; | ||
export * from "../register/client"; | ||
export * from "../lib/streams/consumeEventStream"; | ||
export * from "../clientNavigation"; |
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.
Nice!