Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/docs/navigation-ux.md
Original file line number Diff line number Diff line change
@@ -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 `<Link prefetch>`, 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<string, Promise<unknown>>()

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 `<a data-flight-link>` (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 `<html>` 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 `<a href>` 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.
4 changes: 4 additions & 0 deletions examples/todo/src/client/TodoApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ async function callAction(id: ActionId, args: Record<string, unknown>): 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[]
}

Expand Down
39 changes: 37 additions & 2 deletions examples/todo/src/client/flight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,40 @@ 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<string, Promise<unknown>>()

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<void> {
const root = document.getElementById('view-root')
if (!root) {
location.href = url
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')
}
}

Expand All @@ -104,6 +122,7 @@ export async function streamNavigate(url: string, push = true): Promise<void> {
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')
Expand Down Expand Up @@ -131,6 +150,10 @@ export async function streamNavigate(url: string, push = true): Promise<void> {
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)
Expand All @@ -143,6 +166,8 @@ export async function streamNavigate(url: string, push = true): Promise<void> {
}
} catch {
location.href = url
} finally {
document.documentElement.removeAttribute('data-flight-pending')
}
}

Expand All @@ -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)
})
Expand Down
24 changes: 24 additions & 0 deletions examples/todo/src/client/todo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
}
23 changes: 23 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading