diff --git a/docs/docs/navigation-ux.md b/docs/docs/navigation-ux.md
new file mode 100644
index 0000000..671b5c2
--- /dev/null
+++ b/docs/docs/navigation-ux.md
@@ -0,0 +1,69 @@
+---
+sidebar_position: 13
+---
+
+# Navigation UX: prefetch, pending, redirect
+
+Flight navigation ([Flight navigation](./flight-navigation), [Streaming Flight](./streaming-flight)) already swaps views without a full reload. Three small additions bring it closer to what an RSC router does out of the box: it fetches routes before you click them, it shows that something is happening while it fetches, and a server action can send you somewhere else.
+
+> **React devs:** this is ``, a route-transition pending state, and `redirect()` from a server action — the same three things Next.js's App Router gives you, built directly on the Flight primitives from the previous pages.
+>
+> **PHP devs:** none of this needs a framework. It's a `Map` cache, a data attribute toggled around a `fetch`, and a JSON envelope your existing `header('Location: ...')` call already had a use for.
+
+## Prefetch on hover
+
+`examples/todo/src/client/flight.ts` keeps a small cache of in-flight Flight fetches:
+
+```ts
+const prefetchCache = new Map>()
+
+function prefetch(url: string): void {
+ if (prefetchCache.has(url)) return
+ prefetchCache.set(url, fetch(url, { headers: { 'X-Flight': '1' } }).then((res) => res.json()))
+}
+```
+
+`initFlight()` calls `prefetch(link.href)` on `mouseover` of any `` (same-origin only). By the time the click handler runs `navigate()`, the payload is often already on the wire — `navigate()` just awaits the cached promise instead of starting a new fetch, then clears the cache entry so the next hover re-fetches fresh data.
+
+## Pending indicator
+
+Both `navigate()` and `streamNavigate()` set `data-flight-pending="1"` on `` before the fetch and remove it once the user has something to look at — `streamNavigate()` clears it as soon as the shell (first row) is rendered, not after every boundary resolves, since that's the point where the transition reads as "done" to the user. A `finally` guarantees it's removed even if the fetch fails and the runtime falls back to a real navigation.
+
+`todo.css` turns that attribute into a slim top progress bar with no extra markup:
+
+```css
+:root[data-flight-pending]::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 3px;
+ width: 40%;
+ background: var(--accent);
+ animation: flight-pending-slide 1s ease-in-out infinite;
+}
+```
+
+## `redirect()` in a server action
+
+A server action sometimes needs to send the user elsewhere — clear the list and jump to a summary page, for example. `redirect()` (in `src/functions.php`) is the PHP counterpart of calling `redirect()` from a Next.js server action:
+
+```php
+use function Attitude\PHPX\Server\redirect;
+
+action('todo/clearAndReview', function () use ($store) {
+ $store->clearCompleted();
+ redirect('/stats');
+});
+```
+
+It checks how the action was called and responds accordingly:
+
+- **Flight/JSON request** (`Flight::wants()`, or the action's own `Content-Type: application/json` fetch) — it can't follow an HTTP redirect the way a browser navigation would, so `redirect()` emits `{"__redirect": "/stats"}` instead.
+- **Plain form POST** — a normal `Location:` header and HTTP status (303 by default).
+
+The todo island's `callAction()` in `TodoApp.tsx` checks the response for `__redirect` and, if present, does a hard `window.location.href` navigation instead of treating the payload as `{ todos }`. No current action in the demo redirects — this just makes the client honor one if it does.
+
+## Progressive by default
+
+Prefetch and the pending bar are enhancements layered on JavaScript that was already optional: without JS, links are ordinary `` navigation and there's nothing to prefetch or show pending state for — the browser's own loading indicator does that job. `redirect()` works either way, since both branches were already reachable from the existing action-dispatch code path.
diff --git a/examples/todo/src/client/TodoApp.tsx b/examples/todo/src/client/TodoApp.tsx
index 4bb1b77..1bb640f 100644
--- a/examples/todo/src/client/TodoApp.tsx
+++ b/examples/todo/src/client/TodoApp.tsx
@@ -11,6 +11,10 @@ async function callAction(id: ActionId, args: Record): Promise<
body: JSON.stringify({ id, args }),
})
const data = await res.json()
+ if (typeof data.__redirect === 'string') {
+ window.location.href = data.__redirect // hard navigation, page unloads
+ return []
+ }
return data.todos as Todo[]
}
diff --git a/examples/todo/src/client/flight.ts b/examples/todo/src/client/flight.ts
index d2401f3..d3e5954 100644
--- a/examples/todo/src/client/flight.ts
+++ b/examples/todo/src/client/flight.ts
@@ -73,6 +73,19 @@ function setActive(pathname: string): void {
})
}
+// Prefetch cache: hovering a flight link warms the Flight payload before the
+// click lands, so the navigation below can await the already-inflight
+// request instead of starting a fresh one.
+const prefetchCache = new Map>()
+
+function prefetch(url: string): void {
+ if (prefetchCache.has(url)) return
+ prefetchCache.set(
+ url,
+ fetch(url, { headers: { 'X-Flight': '1' } }).then((res) => res.json())
+ )
+}
+
async function navigate(url: string, push: boolean): Promise {
const root = document.getElementById('view-root')
if (!root) {
@@ -80,15 +93,20 @@ async function navigate(url: string, push: boolean): Promise {
return
}
+ document.documentElement.setAttribute('data-flight-pending', '1')
try {
- const res = await fetch(url, { headers: { 'X-Flight': '1' } })
- const tree = (await res.json()) as unknown
+ const cached = prefetchCache.get(url)
+ prefetchCache.delete(url) // re-fetch next time, even on failure
+
+ const tree = (await (cached ?? fetch(url, { headers: { 'X-Flight': '1' } }).then((res) => res.json()))) as unknown
root.replaceChildren(toNode(tree))
mountIslands(root)
setActive(new URL(url).pathname)
if (push) history.pushState({ flight: true }, '', url)
} catch {
location.href = url // fall back to a real navigation
+ } finally {
+ document.documentElement.removeAttribute('data-flight-pending')
}
}
@@ -104,6 +122,7 @@ export async function streamNavigate(url: string, push = true): Promise {
return
}
+ document.documentElement.setAttribute('data-flight-pending', '1')
try {
const res = await fetch(url, { headers: { 'X-Flight-Stream': '1' } })
if (!res.body) throw new Error('no stream')
@@ -131,6 +150,10 @@ export async function streamNavigate(url: string, push = true): Promise {
setActive(new URL(url).pathname)
if (push) history.pushState({ flight: true }, '', url)
first = false
+ // The shell (with fallback placeholders) is now visible — the point
+ // the user perceives the navigation as done, even if boundaries are
+ // still streaming in.
+ document.documentElement.removeAttribute('data-flight-pending')
} else {
const row = msg as { b: number; tree: unknown }
const slot = document.getElementById('F:' + row.b)
@@ -143,6 +166,8 @@ export async function streamNavigate(url: string, push = true): Promise {
}
} catch {
location.href = url
+ } finally {
+ document.documentElement.removeAttribute('data-flight-pending')
}
}
@@ -159,6 +184,16 @@ export function initFlight(): void {
void navigate(link.href, true)
})
+ // Prefetch on hover: warm the Flight payload as soon as the pointer enters
+ // a link, so the click above often just awaits an already-inflight fetch.
+ document.addEventListener('mouseover', (event) => {
+ const target = event.target as HTMLElement | null
+ const link = target?.closest?.('a[data-flight-link]') as HTMLAnchorElement | null
+ if (!link || link.origin !== location.origin) return
+
+ prefetch(link.href)
+ })
+
window.addEventListener('popstate', () => {
void navigate(location.href, false)
})
diff --git a/examples/todo/src/client/todo.css b/examples/todo/src/client/todo.css
index 722b7c1..c805cca 100644
--- a/examples/todo/src/client/todo.css
+++ b/examples/todo/src/client/todo.css
@@ -393,3 +393,27 @@ body {
font-weight: 700;
color: var(--accent);
}
+
+/* Pending indicator: a slim indeterminate bar shown while a Flight
+ navigation is in flight. Toggled by flight.ts via a `data-flight-pending`
+ attribute on the root element — no extra markup needed. */
+:root[data-flight-pending]::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 999;
+ height: 3px;
+ width: 40%;
+ background: var(--accent);
+ animation: flight-pending-slide 1s ease-in-out infinite;
+}
+
+@keyframes flight-pending-slide {
+ 0% {
+ left: -40%;
+ }
+ 100% {
+ left: 100%;
+ }
+}
diff --git a/src/functions.php b/src/functions.php
index e9140ab..b880c77 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -107,3 +107,26 @@ function cache(callable $fn): callable
{
return fn (mixed ...$args) => Cache::memoize($fn, $args);
}
+
+/**
+ * Redirect the client to $url — usable inside a server action, the PHP
+ * equivalent of calling `redirect()` from a Next.js/RSC server action.
+ *
+ * A Flight/JSON request (client-driven navigation, or the JSON fetch a server
+ * action call makes) can't follow a raw HTTP redirect the way a full page
+ * load would, so it gets a small JSON envelope the client understands
+ * instead; any other request gets a real HTTP redirect.
+ */
+function redirect(string $url, int $status = 303): never
+{
+ $isJson = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json');
+
+ if (Flight::wants() || $isJson) {
+ header('Content-Type: application/json');
+ echo json_encode(['__redirect' => $url]);
+ exit;
+ }
+
+ header('Location: ' . $url, true, $status);
+ exit;
+}