From 9bc658c7f1c96bea8c55f6c82bd6303337323290 Mon Sep 17 00:00:00 2001 From: Martin Adamko Date: Tue, 7 Jul 2026 01:41:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20navigation=20UX=20=E2=80=94=20prefetch,?= =?UTF-8?q?=20pending,=20redirect()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hover prefetch and a top pending bar to Flight navigation, plus a redirect() helper for server actions that responds with a JSON envelope on Flight/JSON requests and a real HTTP redirect otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/navigation-ux.md | 69 ++++++++++++++++++++++++++++ examples/todo/src/client/TodoApp.tsx | 4 ++ examples/todo/src/client/flight.ts | 39 +++++++++++++++- examples/todo/src/client/todo.css | 24 ++++++++++ src/functions.php | 23 ++++++++++ 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 docs/docs/navigation-ux.md 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; +}