Skip to content
Back to skills

Navigation Speed

ASecurity

Page-to-page navigation — prefetch, Speculation Rules, bfcache breakers, instant loading UI, full reloads, scroll, View Transitions, route announcement.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentsgoshellbashreactvueangularnodegitapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill navigation-speed --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Navigation Speed?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Navigation Speed
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-navigation-speed/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-navigation-speed)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: navigation-speed
description: Page-to-page navigation — prefetch, Speculation Rules, bfcache breakers, instant loading UI, full reloads, scroll, View Transitions, route announcement.
kind: skill
pack: frontend
allowed-tools: [Read, Grep, Glob, Bash]
---

# navigation-speed

## Premise

A real session is dominated by page-to-page navigation, not the first cold load. The first paint is measured to death (Lighthouse, CWV); the 2nd → 50th navigation usually isn't measured at all, and that's where "the app feels slow" actually lives. This skill audits the levers that make the NEXT page appear instantly: prefetch the route before the click, serve from bfcache on back/forward, paint a layout-stable skeleton the instant a navigation starts, and never hard-reload when a soft navigation would do.

Every finding cites `<file:line>` + the matched pattern + a concrete fix + a closure verb. "Navigation feels sluggish" without a cited link / handler / missing boundary is not a finding. A scan that returns zero findings on a multi-route app without listing the patterns it grepped is a failed scan.

**Closure verbs (one per finding)** — what each means here:
- `report-with-fix` — pattern matched at `<file:line>` + the concrete prefetch / boundary / `router.push` / `pagehide` patch.
- `report-flagged` — measured-relevant but the fix is an architectural call (adopt Speculation Rules host-wide, restructure a layout) → surface for ADR, naming who decides.
- `dismiss` — pattern matched but the carve-out applies (auth-mutating link, logout prerender, pagination tail) → documented so the next scan doesn't re-flag it.

## Pre-flight greps

Run as **one** script from the repo root; shell state does not survive between tool calls. Grep **N** feeds detector **§N**. Every `rg` names its path: given no path and a piped stdin, `rg` reads stdin and reports zero hits. Files-without-match is `--files-without-match`, never `-L` (that is `--follow` in ripgrep). An `rg` error on stderr (exit 2) is a failed grep, not zero hits.

```bash
ROOTS=( $(rg -l --pcre2 '"(?:react|react-dom|vue|svelte|@angular/core|solid-js|preact|astro|next|nuxt)"\s*:' -g '**/package.json' -g '!**/node_modules/**' . | xargs -n1 dirname | sort -u) ); [ ${#ROOTS[@]} -eq 0 ] && ROOTS=(.)
X=(-g '!**/node_modules/**' -g '!**/{dist,build,out,coverage,.next,.nuxt,.output,.svelte-kit,.angular,storybook-static}/**' -g '!**/*.min.*'
   -g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,__fixtures__,stories,playwright,cypress,locales,i18n,generated,__generated__,docs}/**'
   -g '!**/*.{test,spec,e2e,stories,story,mock}.*' -g '!*.{md,mdx,snap,map,lock}' -g '!ai/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#|<!--|\{/\*)')
UI=(-g '*.{js,jsx,ts,tsx,mjs,vue,svelte,astro,html}')
CMP=(-g '*.{jsx,tsx,vue,svelte,astro}' -g '*.component.html')
CODE=(-g '*.{js,jsx,ts,tsx,mjs,vue,svelte,astro}')
# 0. Lanes: the client router and its prefetch primitive (§ Prefetch primitive per framework); no router = a document-navigation surface.
rg -n --no-heading '"(next|nuxt|@sveltejs/kit|react-router|react-router-dom|@tanstack/react-router|vue-router|@angular/router|astro|quicklink|ngx-quicklink)"\s*:' -g '**/package.json' -g '!**/node_modules/**' "${ROOTS[@]}"
# 1. Internal links that do not prefetch: a raw <a href="/…"> in a component (1a); a link with prefetch turned off (1b).
rg -n --no-heading --pcre2 '<a\b[^>]*\bhref=["\x27{`]*/(?![/\\])' "${CMP[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\bprefetch=\{?\s*false\b|:prefetch=["\x27]false|data-sveltekit-preload-(?:data|code)=["\x27]off|\bpreload=\{?\s*false\b' "${CMP[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 2. Speculation Rules present (2a) — the detector fires only on a document-navigation surface (§2 conditions).
rg -n --no-heading --pcre2 'type=["\x27]speculationrules|Speculation-Rules\s*:' "${UI[@]}" -g '**/{nuxt,next,astro}.config.*' -g '**/_headers' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 3. bfcache breakers: unload / beforeunload listeners (3a); long-lived connections — WebSocket, socket.io `io(…)`,
#    EventSource, BroadcastChannel, IndexedDB (3b) — in files that never listen for pagehide (3c).
rg -n --no-heading --pcre2 'addEventListener\(\s*["\x27](?:unload|beforeunload)["\x27]|\bon(?:before)?unload\s*=|useEventListener\([^,]*,\s*["\x27](?:before)?unload' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 '\bnew\s+(?:WebSocket|BroadcastChannel|EventSource|Manager)\s*\(|\bio\s*\(|from\s+["\x27]socket\.io-client["\x27]|\bindexedDB\.open\s*\(|\bopenDB\s*\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -r rg --files-without-match 'pagehide'
# 4. Instant-loading UI: Next App Router page dirs with no loading.* (4a); pending-state primitives in use — React Router useNavigation,
#    Nuxt <NuxtLoadingIndicator>, Vue/React <Suspense>, SvelteKit navigating (4b); the deprecated SvelteKit $app/stores import (4c).
rg --files -g '**/app/**/page.{tsx,jsx,ts,js}' "${X[@]}" "${ROOTS[@]}" | while read -r f; do ls "$(dirname "$f")"/loading.* >/dev/null 2>&1 || echo "$f"; done
rg -n --no-heading --pcre2 '\buseNavigation\(\)|\buseRouterState\(|\bnavigating\b|<Suspense\b|\bisNavigating\b|\buseLoadingIndicator\(|<NuxtLoadingIndicator\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" | wc -l
rg -n --no-heading --pcre2 'from\s+["\x27]\$app/stores["\x27]' -g '*.{svelte,ts,js}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 5. Full reload where a soft navigation belongs (an assignment, not a comparison).
rg -n --no-heading --pcre2 '\b(?:window\.|document\.)?location\.(?:href|pathname)\s*=(?!=)|\blocation\.(?:assign|replace)\s*\(|\bwindow\.location\s*=(?!=)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 6. Manual scroll restoration.
rg -n --no-heading --pcre2 'scrollRestoration\s*=\s*["\x27]manual' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 7. View Transitions in use (7a) and whether a reduced-motion guard exists anywhere in the styles (7b).
rg -n --no-heading --pcre2 'startViewTransition\s*\(|view-transition-name|@view-transition|viewTransition\s*:|withViewTransitions\(|<ViewTransitions\b' "${CODE[@]}" -g '*.{css,scss}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -c --pcre2 'prefers-reduced-motion' -g '*.{css,scss,sass,less,vue,svelte,tsx,jsx,ts}' "${X[@]}" "${ROOTS[@]}"
# 8. Route-change announcement: live regions, focus moves (`.focus()` and `.focus({ preventScroll: true })`), framework announcers.
rg -n --no-heading --pcre2 'aria-live=|role=["\x27]status["\x27]|<NuxtRouteAnnouncer\b|\.focus\(\s*(?:\{[^)]*\})?\s*\)' "${CODE[@]}" -g '*.component.html' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
```

A hit is a candidate. Two blind spots are structural:

- **A listener added by a library is invisible to grep 3a.** Analytics, chat and session-replay SDKs register `unload` / `beforeunload` themselves. The Lighthouse `bf-cache` audit's `notRestoredReasons` (same id in 12 and 13) is the ground truth; the grep only finds the first-party ones.
- **Grep 1a sees literal paths only.** A raw anchor whose `href` is built from a variable (`:href="url"`, `href={link}`) is not listed; read the shared link components instead.

**Measured** (2026-10-05, read-only). Scratch fixture: a raw internal anchor, a `preload-data="off"` link, an `unload` listener, a `location.href` assignment, a page with no `loading.tsx` and a `$app/stores` import, all hit; the external and protocol-relative anchors, the comparison `location.href === …`, and the WebSocket in a file that listens for `pagehide` stay out. Benchmark fixtures: 1a hits once each in `quality-mix` and `vuln-fullstack`; 8 lists the existing live regions; the rest 0. Real repos: a Nuxt 4 storefront (3a: 2 `beforeunload` listeners in an engagement tracker; 5: 1 post-checkout redirect, a legitimate cross-origin carve-out on read; 1a: 4, of which 2 are anchors inside translated HTML strings), a Vue 3 + Vite admin SPA (5: 1 hard reload in a router guard) and an Angular 19 template (5: 6, four of them `mailto:` assignments, which are not navigations). Re-measured 2026-10-15 on a real NestJS + React monorepo (Vite SPA, socket.io realtime): 3b 0 → 1 — the app's socket bus opens `io(env.wsUrl, …)` and never listens for `pagehide`, a bfcache blocker the WebSocket-only pattern missed; 8: 56 → 61 lines, five new `.focus({ preventScroll: … })` calls, two of them the portal shell's route-change focus on `<main>` that the bare `.focus()` pattern had missed (the other three move focus inside a form, a popover and a landing tour). Fixtures: unchanged.

## The 3,000-hit path

1. **Group** by (shared component or module, pattern). One `NavLink` wrapper, one analytics module or one router guard is one finding for every caller.
2. **Rank** by route traffic and by navigation frequency (primary nav, list → detail, back from detail).
3. **Read the top 30 groups.** Ledger the rest as `Live, unreviewed (k groups / n hits)` with the command that continues them, never `checked`.

## Scans for

### 1. Internal links that don't prefetch

The framework's link primitive prefetches the next route's code (and often data) before the click — usually on viewport-entry or hover. A raw `<a>` to an internal route, or a link with prefetch explicitly disabled, pays the full round-trip on click.

```
BAD:
<a href="/products/42">View</a>          // raw anchor — no prefetch, full reload
<NuxtLink to="/x" :prefetch="false">     // prefetch turned off without a reason

GOOD:
<Link href="/products/42">View</Link>    // Next: auto-prefetch in-viewport (prod)
<NuxtLink to="/x">                        // Nuxt: prefetches in-viewport by default
<a href="/x" data-sveltekit-preload-data="hover">  // SvelteKit
```

Grep: raw `<a href="/` (app-internal paths) in framework component files; `prefetch=\{?false`, `:prefetch="false"`, `data-sveltekit-preload-data="off"`. Per-framework primitives → §"Prefetch primitive per framework".

### 2. No Speculation Rules on a content/MPA surface

For multi-page apps (or any surface where the framework link doesn't prerender), the Speculation Rules API lets the browser **prerender** the next document in the background — the navigation then resolves in ~0ms.

```
GOOD (in <head> or injected):
<script type="speculationrules">
{ "prerender": [{ "where": { "href_matches": "/*" },
                  "eagerness": "moderate" }] }
</script>
```

`eagerness`: `immediate` / `eager` / `moderate` (hover ~200ms) / `conservative` (pointerdown). Prefer `prefetch` (cheaper, no JS execution) when prerender is too aggressive. Grep for `type="speculationrules"`.

**This detector only fires on a surface the framework link primitive cannot already serve** — otherwise it proposes a second prefetch mechanism beside a working one. Two conditions, both required, both checkable:

1. **The navigation is a document navigation.** Either the project ships no client router (no entry in the § Prefetch-primitive table matches its stack), or the specific links in question leave the router's control — `target="_blank"`, a cross-app link inside a monorepo, a link into a separately-deployed marketing or docs surface, a server-rendered pagination link.
2. **The next document is prerenderable** — a GET with no side effect, no per-click personalisation that a prerender would resolve early and stale.

Fail either → `dismiss` with the reason (`SPA: <Link> already prefetches` / `side-effecting GET`). Pass both and no `speculationrules` block exists → `report-flagged`: adopting it is a host-wide `<head>` change with a `where` allow-list somebody has to own, not a mechanical edit.

### 3. bfcache (back/forward cache) breakers

Back/forward navigations should restore the page instantly from memory — no re-fetch, no re-render. The page becomes **ineligible** if it ships disqualifying code.

```
BAD:
window.addEventListener('unload', save);        // HARD disqualifier
window.addEventListener('beforeunload', warn);  // disqualifies unless conditionally added

GOOD:
window.addEventListener('pagehide', save);
window.addEventListener('visibilitychange', save);  // most robust persistence hook
```

Grep: `addEventListener\(['"](unload|beforeunload)`. Also flag open `IndexedDB` / `WebSocket` / `BroadcastChannel` not closed on `pagehide` (these hold the page out of bfcache). `Cache-Control: no-store` on the document is a **warn/review**, not a hard fail — Chrome rolled bfcache for no-store ("CCNS") to 100% in 2025 where safe; the hard disqualifier remains the `unload` listener. Cite the actual reason from a Lighthouse `bf-cache` audit when claiming ineligibility (see §"Verify").

### 4. Missing instant-loading UI on navigation

When a navigation starts, the user must see a layout-stable skeleton **immediately** — not a spinner, not a frozen old page, not a blank screen. Otherwise even a fast route feels slow because nothing acknowledges the click.

```
BAD:  app/products/page.tsx exists, no app/products/loading.tsx  (Next App Router)
BAD:  <RouterView/> with no pending/transition state         (Vue/React Router)

GOOD: app/products/loading.tsx → streams a skeleton instantly
GOOD: const nav = useNavigation(); nav.state === 'loading' → <Skeleton/>  (React Router)
GOOD: import { navigating } from '$app/state'   // SvelteKit ≥ 2.12 (Svelte 5); `$app/stores` is the deprecated form (grep 4c)
```

The skeleton MUST match final dimensions (reserve width/height) — a skeleton that reflows just trades a blank screen for a CLS hit. Grep route segments lacking a sibling loading convention; flag router outlets with no pending-state branch.

### 5. Full reload where a soft navigation belongs

Programmatic navigation through `window.location` throws away the SPA — full document reload, re-parse, re-hydrate, lost client state.

```
BAD:   window.location.href = '/dashboard';
       location.assign('/orders/' + id);

GOOD:  router.push('/dashboard');           // Vue / Next useRouter / SvelteKit goto
       navigate('/orders/' + id);            // React Router
```

Grep: `(window\.)?location\.(href|assign|replace)\s*=` and `location\.(assign|replace)\(` in component / handler code. Carve-out: cross-origin redirects, post-logout, and intentional hard reloads after a deploy-version mismatch are legitimate → `dismiss` with the reason.

### 6. Scroll restoration mishandled

On back/forward the browser restores scroll position; a route that sets `history.scrollRestoration = 'manual'` without re-implementing restoration leaves users at the top of a long list they scrolled. Grep `history.scrollRestoration\s*=\s*['"]manual` with no paired restore logic.

### 7. View Transitions as a perceived-instant mechanism

The View Transitions API cross-fades / morphs between states so a soft navigation *feels* continuous instead of a hard swap. It is a **perception** layer — never a substitute for real prefetch/streaming (a slow route with a pretty transition is still slow).

```
Same-document:  document.startViewTransition(() => updateDOM());
Cross-document: @view-transition { navigation: auto; }   /* CSS, MPA */
Shared element: view-transition-name: hero-<id>;
```

Always gate motion behind `@media (prefers-reduced-motion: reduce)`. Flag a shared-element transition with no `view-transition-name`, or a `startViewTransition` that wraps an async data fetch (it should wrap the DOM update only, after data is ready).

### 8. Soft navigation that never announces itself (focus + live region)

A client-side navigation swaps the DOM without a document load, so **the screen reader says nothing and focus stays on a link that no longer exists**. The user hears silence, then lands mid-document with no idea the page changed. Every lever above makes navigation faster; this one makes it perceivable. It is the a11y consequence of the mechanism this skill owns, which is why the detector lives here — `@accessibility-auditor` grades conformance, this scan finds the missing wiring.

```
BAD:  router.push('/orders')            // that is the whole handler; nothing else happens
GOOD: after the route resolves, move focus to the new view's <h1> (tabindex="-1" + .focus()),
      OR announce the new document title through ONE app-shell live region:
      <p aria-live="polite" class="sr-only">{routeAnnouncement}</p>
```

Grep 8 lists the live regions, `role="status"` and `.focus()` calls (ripgrep exits 2 on a missing path, which reads like "no findings" if you only check the output) — zero hits in a project that ships a client router is the finding. Then check the opposite failure: **one** live region in the app shell, not one per view (N regions announce N times, or fight each other). Carve-out: a router that already ships route announcement (some do) — cite the router's mechanism and `dismiss`.

## Prefetch primitive per framework

| Framework | Prefetch primitive | Default | Disable / tune |
|---|---|---|---|
| **Next (App Router)** | `<Link>` (+ `useRouter().prefetch()`) | auto-prefetch in-viewport in **prod** | `prefetch={false}`; `prefetch={null}` = partial (default) |
| **Nuxt** | `<NuxtLink>` (+ `prefetchComponents`) | prefetches in-viewport links | `:prefetch="false"`; `prefetchOn="interaction"` |
| **SvelteKit** | `data-sveltekit-preload-data` | off until set (often on `<body>`) | `"hover"` / `"tap"` / `"off"`; code: `data-sveltekit-preload-code="eager\|viewport\|hover\|tap"` |
| **React Router** | `<Link prefetch>` | `"none"` | `"intent"` (hover/focus) / `"render"` / `"viewport"` |
| **TanStack Router** | `<Link preload>` | off | `preload="intent"` (router default configurable) |
| **Angular** | route `PreloadingStrategy` | none | `withPreloading(PreloadAllModules)` or `ngx-quicklink` |
| **Plain HTML / MPA** | Speculation Rules + `<link rel="prefetch">` | none | `eagerness` tuning |

## Output

```
Navigation-speed audit — <route set or full scan>

Per-route table:
  route          prefetch  loading-UI  bfcache   spec-rules      finding
  /products      ✓ (Link)  ✗           ✓         dismissed (SPA) missing loading.tsx
  /orders/[id]   ✗ (<a>)   ✓           ⚠         dismissed (SPA) raw anchor → <Link>
  /docs/*        ✗ (MPA)   n/a         ✓         ABSENT          no speculationrules on a document-nav surface
  /reports       ✓         ✓           ✗ unload  dismissed (SPA) bfcache-blocked

The `spec-rules` column never prints `n/a`: detector 2 either fired, was `dismiss`ed with its reason, or found the block present. `n/a` on every row is what a column looks like when the detector never ran, and it is indistinguishable from a clean result.

Findings: 3

1. components/OrderRow.vue:18                          [report-with-fix]
   Raw <a href="/orders/{{id}}"> for internal nav — no prefetch, full reload.
   Fix: <RouterLink :to="`/orders/${id}`"> (vue-router does NOT auto-prefetch
        the chunk — warm import('./OrderDetail.vue') on hover, or adopt quicklink).

2. app/products/  (segment)                            [report-with-fix]
   No loading.tsx beside a route whose page awaits a slow server fetch.
   Fix: add app/products/loading.tsx returning a dimension-matched skeleton;
        wrap the slow subtree in <Suspense> so the shell streams first.

3. lib/analytics.ts:44                                 [report-with-fix]
   window.addEventListener('unload', flush) — HARD bfcache disqualifier.
   Fix: switch to 'pagehide' (or 'visibilitychange' + navigator.sendBeacon).

Verification: 14 candidates → 3 findings · 1 unconfirmed · 10 refuted

Class coverage:
  §1  no prefetch               checked (4 hits → 1 finding)
  §2  speculation rules         checked (SPA: <Link> already prefetches → dismissed; /docs/* → 1 unconfirmed, report-flagged)
  §3  bfcache breakers          checked (1 hit → 1 finding; bf-cache audit confirms)
  §4  instant-loading UI        checked (2 segments → 1 finding)
  §5  full reload               checked (3 hits → 0 findings; logout and cross-origin carve-outs)
  §6  scroll restoration        checked (0 hits)
  §7  View Transitions          N/A (none in use)
  §8  route announcement        checked (Next App Router announcer; dismissed with its mechanism)
```

`[report-with-fix]`: a cited line and a local patch. `[report-flagged]`: an architectural call (Speculation Rules host-wide, a layout restructure) or an unproven link; the row names who decides or the run that settles it. The coverage block lists **all eight** classes on every run: `checked (k hits → m findings)`, `N/A (<reason>)`, or `blocked (<reason>)` when a grep failed. A partial review reads `Live, unreviewed (k groups / n hits)`, never `checked`.

## False positives / gotchas

- `prefetch={false}` is CORRECT on auth-mutating links, expensive dynamic pages, and the long tail of paginated links — flag only the *primary* in-viewport nav. Treat an intentional `data-sveltekit-preload-data="off"` the same way.
- Speculation Rules **prerender** must NEVER target logout, delete, or any GET that triggers a side effect — prerender executes the page. Use `prefetch` for those, or exclude them with `where`.
- bfcache eligibility must be cited from a real Lighthouse `bf-cache` audit (it reports the actual `notRestoredReasons`) — a grep is a *candidate*, the audit is ground truth.
- `vue-router`'s `<router-link>` and plain React Router (without the framework) do NOT auto-prefetch route chunks the way Next `<Link>` / `<NuxtLink>` do — don't assume a prefetch you didn't wire.
- View Transitions degrade gracefully (unsupported browsers just hard-swap) — don't block a navigation waiting for the API.

## When to run

- After adding routes or a new navigation surface (nav bar, command palette, deep links).
- Before shipping a logged-in, multi-page app — internal navigation is the dominant interaction.
- On any "the app feels slow when I click around" report (distinct from "the first load is slow" → `lighthouse-ci` in this pack, or `bundle-perf` *(performance pack, when co-installed)*).
- Pair with `streaming-ssr` (this pack) when a slow route blocks the navigation on a server query. Field INP on the *navigated-to* route comes from `web-vitals-field` *(performance pack, when co-installed)*; absent that pack this scan is static only — report `INP: no field source` rather than inferring one from the greps.

## Halt conditions

- Halt if detector 8 is reported as PASS without naming either the focus target or the live region at `<file:line>` — "the router handles it" is a claim, and the claim is checkable.
- Halt on hand-waves: every prefetch / bfcache / reload / loading-UI finding cites `<file:line>` + the matched pattern + a concrete fix. "Could prefetch more" without a link is not a finding.
- Halt if a bfcache-ineligible claim isn't backed by either the grepped `unload`/`beforeunload` line OR a Lighthouse `bf-cache` audit reason.
- Halt if Speculation Rules are proposed without excluding side-effecting GETs (logout/delete) from the `prerender` set.
- Halt if an instant-loading fix proposes a spinner or an unsized skeleton (CLS) instead of a dimension-matched one.
- Halt if a default framework prefetch is disabled in a fix without a documented reason.
- Halt before reporting any candidate that has not been through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) (§1 restate … §9) and [`precedents/ui.md`](../../../../snippets/precedents/ui.md) § 27 (built-in route announcers). **Proof:** §3 the `bf-cache` audit passes (same id in Lighthouse 12 and 13) and a Playwright back navigation reports `persisted === true` on `pageshow`; §1 and §4 the next route's chunk is requested before the click (`page.on('request')`) and the skeleton paints before the data resolves; §8 the focus target or live-region text is asserted after a client navigation.

## Related

- `code-splitting.md` (ai-pattern) — this skill owns prefetching the likely-next route's chunk on hover/idle so the split click resolves from cache; that pattern owns where the chunk boundary is cut.
- `list-virtualization.md` (ai-pattern) — restore a windowed list's scroll by first-visible index, not pixel `scrollTop`, on back/forward + bfcache restore.
- `lighthouse-ci` — owns the cold first load and reports `bf-cache` ineligible; this skill owns every navigation after it and names the listener that caused the ineligibility.
- Cross-pack (`performance`, when co-installed): `web-vitals-field` confirms whether a prefetch/bfcache win actually reached users. Absent that pack this skill's output is a static claim, not a measurement, and the report must say which.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.