# PLAN.md — SPA-mode navigation for mini-static
## Where We Are
`mini-static` serves every request as a normal HTTP GET/HEAD against a file
under its canonicalized root (`src/server.rs::handle_request`). Multi-page
navigation today is entirely the browser's default behavior: clicking any
`<a href>` triggers a full document unload/request/parse/render cycle. There
is no client-side interception, no `history.pushState`, and no way to animate
between pages — a click looks identical to typing a new URL, and any
in-memory JS state on the page is discarded on every navigation.
The crate already has one precedent for injecting behavior into served HTML:
when `Server::with_live_reload()` is enabled, `handle_request` reads the full
HTML body into memory (gated by `self.broadcaster.is_some() &&
content_type.starts_with("text/html")`, `src/server.rs:920`) and calls
`reload::inject_reload_script` (`src/reload.rs:143`), which splices a fixed
`<script>` immediately before `</body>` (or `</BODY>`), falling back to
appending it if no closing body tag is found. That injected script opens an
`EventSource` to `LIVE_RELOAD_PATH` and either hot-swaps `<link
rel=stylesheet>` hrefs (CSS changes) or calls `location.reload()` (script/html/
other changes) — it does not touch normal link navigation at all.
Without live-reload enabled (the default), HTML responses are served
byte-for-byte unmodified.
## Where We Need To Be
Two new opt-in `Server` builder methods, both off by default (zero behavior
change for existing callers):
- `Server::with_spa_mode()` — enables SPA navigation, swap target is
`document.body`.
- `Server::with_spa_root(selector: &str)` — enables SPA navigation, swap
target is the element matched by CSS `selector` (implies `with_spa_mode`).
When enabled, every served `text/html` response gets one additional
`<script>` spliced before `</body>` (same anchor/fallback logic as the
live-reload script; the two compose — see Commit 3). In a browser, that
script:
1. **Intercepts navigation clicks.** Left-clicks (no modifier key) on
same-origin `<a href>` elements are intercepted, *except* when the link:
has `target` other than empty/`_self`, has a `download` attribute, has
`rel="external"`, carries a `data-no-spa` attribute, or is a same-page
hash-only link (same path+search, different `#fragment`) — those fall
through to normal browser navigation untouched.
2. **Fetches and swaps.** On intercept: `fetch()`s the href. A non-OK
response or a response whose `Content-Type` isn't `text/html` falls back
to a real full navigation (`location.href = url`) — SPA nav degrades to
normal nav, it never renders a broken page. On success: parses the
response body, replaces the configured root element's `innerHTML` with
the corresponding root content from the fetched document, sets
`document.title`, and calls `history.pushState` with the fetch's final
(post-redirect) `response.url`.
3. **Animates the swap.** Wraps the DOM swap in `document.startViewTransition()`
when the browser supports it (a default cross-fade with zero required
CSS; a site can override it via `::view-transition-old/new` rules), and
swaps synchronously when it isn't supported — no broken behavior on
browsers without View Transitions.
4. **Handles back/forward.** A `popstate` listener re-fetches and swaps to
`location.href` without pushing a new history entry.
5. **Lets pages re-initialize.** After every *client-side* navigation (not
the initial page load), dispatches
`window.dispatchEvent(new CustomEvent('mini-static:navigate', {detail:{url}}))`.
This exists because content swapped in via `innerHTML` never executes
`<script>` tags — any per-page init logic must run on this event (in
addition to normal `DOMContentLoaded`/inline execution on first load) to
fire again on subsequent navigations.
6. **Preserves scroll intent.** Scrolls to the top of the page, or to the
element matching the URL's `#fragment` if one exists in the new content.
7. **Ignores superseded navigations.** If a second navigation starts before
the first's fetch resolves, the first's result is discarded instead of
clobbering the second's.
With spa-mode disabled (the default), HTML response bytes are unaffected —
byte-identical to today. With spa-mode and live-reload both enabled, both
scripts are present in the response, both before `</body>`.
## Commits
### 1. `spa` module — client script + HTML splice (pure, unit-tested)
- Does: Make `reload::find_subsequence` `pub(crate)` (currently private,
`src/reload.rs:154`) so it has one canonical implementation shared by both
injectors instead of a second copy. Add `src/spa.rs` with
`spa_script_tag(root_selector: Option<&str>) -> String` and
`pub(crate) fn inject_spa_script(html: &mut Vec<u8>, root_selector: Option<&str>)`,
which splices before `</body>`/`</BODY>` or appends, reusing
`reload::find_subsequence` rather than duplicating it.
The root selector is embedded as a JSON-escaped string literal (escape `"`,
`\`, and control characters; additionally reject/escape any `</script`
substring so the selector text can never terminate the injected `<script>`
tag early) — this guards a misconfigured `with_spa_root` call from
corrupting every HTML page the server serves, not just the one page a
visitor is viewing.
The generated script's behavior, precisely:
- Click interception, guards (`target`, `download`, `rel="external"`,
`data-no-spa`, hash-only same-page links), same-origin check — as
described in "Where We Need To Be".
- `navigate(url, push)`: `fetch()` first, in full, *before* any transition
starts. Only after the fetched response is validated (OK + `text/html`)
and parsed does the function proceed; a non-OK or non-HTML response
triggers `location.href = url` and returns — no view transition is
started for a fallback navigation.
- The DOM mutation itself — `curRoot.innerHTML = newRootHtml; document.title = newTitle;`
plus `history.pushState`/scroll — is the *only* thing wrapped in
`document.startViewTransition(...)` when supported, so the transition
callback returns immediately rather than awaiting network I/O (required
by the View Transitions API's timing model — wrapping the fetch itself
would stall or visually break the transition). `innerHTML` assignment is
used deliberately over node replacement: it preserves event listeners and
attributes bound to the persistent root element itself (relevant to
`with_spa_root`, where the root container is expected to stay alive
across navigations), only replacing its children.
- `popstate` handling, `mini-static:navigate` dispatch, scroll-to-hash,
and stale-navigation-token guard, all as described above.
- Verifies: unit tests (same shape as `reload.rs`'s existing ones): generated
script contains `mini-static:navigate`; contains the configured selector
literal when `Some(sel)` and references `document.body` (no selector
literal) when `None`; a selector containing a `"` and one containing
`</script` are each escaped such that the surrounding `<script>` tag stays
intact (assert exactly one `<script>` open/close pair in the output);
`inject_spa_script` inserts before `</body>` and `</BODY>`, appends when
neither is present, and is no-op-safe on empty input. All run via
`cargo test`, no server involved.
- Touches: new `src/spa.rs` (~200–260 LOC including tests), one-line
visibility change in `src/reload.rs`.
- Reverts cleanly: yes — deletes an unwired, unreferenced module; the
`find_subsequence` visibility bump is harmless to leave behind on its own
since a `pub(crate)` function nobody calls outside its module doesn't
trigger the dead-code lint the way an unused top-level function would.
### 2. `Server::with_spa_mode()` / `with_spa_root()` + wiring into `handle_request`
- Does: Add `spa_mode: bool` and `spa_root: Option<String>` fields to
`Server`. Add `with_spa_mode(mut self) -> Self` (sets `spa_mode = true`
only — leaves `spa_root` untouched either way) and
`with_spa_root(mut self, selector: &str) -> Self` (sets both
`spa_root = Some(selector.into())` and `spa_mode = true`), so the two
compose regardless of call order: `.with_spa_root("#app").with_spa_mode()`
and `.with_spa_mode().with_spa_root("#app")` both end up with spa-mode on
and root `#app`.
Change the existing `html_injection` gate in `handle_request`
(`src/server.rs:920`) from
`self.broadcaster.is_some() && content_type.starts_with("text/html")` to
`(self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html")` —
this is the load-bearing change; without it, enabling `with_spa_mode()`
alone (no live-reload) leaves `broadcaster` `None` and the gate stays
false, silently no-opping spa-mode. Then, at the existing splice step
(`src/server.rs:968`), call `spa::inject_spa_script(&mut html, self.spa_root.as_deref())`
when `self.spa_mode`, after (or independent of) the existing
`reload::inject_reload_script` call. Because both calls key off the same
widened `html_injection` bool, the sidecar-skip and Range-skip logic that
already branches on that bool (`src/server.rs`, `sidecar`/`range_header`
handling just below) covers spa-mode automatically — no separate change
needed there.
- Verifies: new `tests/spa_mode.rs`, socket-level like `tests/live_reload.rs`:
(a) spa-mode off (default) — response byte-identical to the unmodified
fixture; (b) `with_spa_mode()` — response contains the script referencing
`document.body`; (c) `with_spa_root("#app")` — response contains the script
referencing `#app`, not `document.body`; (d) `with_spa_root("#app").with_spa_mode()`
and `with_spa_mode().with_spa_root("#app")` both produce a response
containing `#app` — proving the order-independence contract.
- Touches: `src/server.rs` (2 fields, ~30 LOC of builders, one gate-boolean
edit, one added call), new `tests/spa_mode.rs` (~90 LOC).
- Reverts cleanly: yes, together with Commit 1. Reverting Commit 2 *alone*
leaves `spa.rs`'s functions unused, which would fail this repo's
`clippy -- -D warnings` CI gate until Commit 1 is reverted too — the two
are meant to ship and revert as a pair, matching how this crate's own
`DEV_PLAN.md` already sequences other features (pure function commit,
then a wiring commit).
### 3. Compose spa-mode with live-reload; regression test
- Does: No production code change — composition already works, because each
injector re-searches the buffer for `</body>` after the previous one's
splice, so sequential calls both land before the (still-present) closing
tag regardless of call order. Add the test that proves it.
- Verifies: extend `tests/spa_mode.rs` with a case enabling both
`with_spa_mode()` and `with_live_reload()`; assert the response contains
both the `EventSource` marker and the `mini-static:navigate` marker, each
individually positioned before `</body>` — the test does not assert a
relative order between the two scripts, since which one lands first is an
implementation detail, not a contract either commit makes.
- Touches: `tests/spa_mode.rs` (+~30 LOC).
- Reverts cleanly: yes — pure test addition, no coupling.
### 4. Example + README docs
- Does: Add an unconditional `.with_spa_root("#app")` call to
`examples/mini-static.rs` — unlike the CSS/JS-bundle demo, which is
gated behind an env var / path check because it depends on an optional
external binary (`lightningcss`/`esbuild`) actually being installed,
spa-mode is pure Rust with no external dependency, so there's no reason to
gate it; it's simply always on in the example. Add a small two-page demo
fixture under the example's `public/` (shared `#app` wrapper plus
persistent header outside it, each page logging to a visible element on
`mini-static:navigate` to prove the reinit hook works), and a README
section documenting: what the two builders do, the `data-no-spa` opt-out,
the `mini-static:navigate` event contract with a listener snippet, the
View Transitions progressive-enhancement behavior and how to customize it,
the explicit limitation that inline `<script>`s inside the swapped root
never execute, and the accepted tradeoff (see Open Questions) that a click
on a same-origin link to a large non-HTML file is fetched once via JS
before falling back to a real navigation — recommend `download` or
`data-no-spa` on such links.
- Verifies: manual — `cargo run --example mini-static`, click between the
two demo pages in a real browser; confirm the URL updates without a full
reload, a transition is visible, back/forward works, the on-page log shows
the event firing each navigation, and a plain click-and-load still works
with JS disabled. This is the one commit in the plan whose primary
verification is manual rather than `cargo test` — see Open Questions.
- Touches: `examples/mini-static.rs` (~20 LOC), 2 new small HTML fixtures,
`README.md` (+~40–60 lines).
- Reverts cleanly: yes — additive docs/example, nothing depends on it.
## Open Questions
- **Accepted tradeoff: large non-HTML files get double-fetched on a missed
opt-out.** A same-origin link to a PDF/zip/etc. without `download` or
`data-no-spa` is `fetch()`ed in full by the interceptor before the
content-type check falls back to a real navigation, which then re-requests
it — a real cost for large files. A code-level heuristic (e.g. skip
interception based on file extension) was considered and rejected: mini-static
serves arbitrary content with no forced extension convention, so an
extension check would be its own source of false negatives. Mitigation is
documentation only (Commit 4: recommend `download`/`data-no-spa` on
non-page links), not code.
- **No headless-browser test coverage.** This crate has zero browser-
automation test infra today; the actual click-interception, transition,
popstate, and reinit-event behavior can only be verified manually via
Commit 4's example. Adding `fantoccini`/`chromiumoxide`-based automated
browser tests is a meaningfully bigger lift than the feature itself —
recommend deferring unless a real regression shows up, but flagging since
it's a real gap relative to this crate's otherwise fully-automated test
culture.
- **Link-exclusion rules are a judgment call.** The proposed exclusions
(`target`, `download`, `rel="external"`, `data-no-spa`, hash-only) follow
common precedent (Turbo, htmx boost) but aren't derived from anything in
this codebase — worth confirming no existing backstack-generated site
relies on link behavior this would change before Commit 4 ships.
- **Selector-not-found fallback.** If `with_spa_root(sel)` is configured but
`sel` matches nothing on either the current or fetched page, the proposed
behavior is silent fallback to a full `location.href` navigation (same
policy as a fetch failure). Confirm that's preferred over a console error
or throwing.
## Grill Verdict
Round: 1
Status: PASS
Findings resolved:
- [Commit 1, G1] Root selector embedded into the script/HTML with no
escaping strategy → JSON-string-escape the selector, additionally guard
against `</script` breaking the tag; added unit tests for both.
- [Commit 1, G3] Fetch-before-transition ordering was implementation-critical
but unstated → made explicit: `startViewTransition`'s callback wraps only
the synchronous DOM mutation, never the network fetch.
- [Commit 1, G3] Swap mechanism (`innerHTML` vs. node replacement) unstated,
though it determines whether listeners on a persistent root container
survive navigation → specified `innerHTML` assignment, with the reason
(preserves the container's own listeners/attributes) stated inline.
- [Commit 1, G4] Dangling "see Open Questions" pointer for sharing
`find_subsequence` with `reload.rs`, never actually added as a question →
resolved directly: `find_subsequence` becomes `pub(crate)` and is reused,
not duplicated.
- [Commit 2, G1/G4] Exact gate-boolean change
(`broadcaster.is_some()` → `broadcaster.is_some() || spa_mode`) was implied
but not spelled out, risking a silent no-op for spa-only configs → written
out verbatim in the commit, flagged as the load-bearing change.
- [Commit 2, G4] Sidecar-skip/Range-skip inheriting spa-mode coverage via the
same bool wasn't stated → added as an explicit reassurance sentence.
- [Commit 2, G3] Builder call-order contract
(`with_spa_root` then `with_spa_mode`, or reverse) was unstated →
`with_spa_mode` now only sets its own flag and never clears `spa_root`, so
both call orders converge on the same state; added a test case covering
both orders.
- [Commit 3, G3] Test was at risk of asserting an unstated ordering contract
between the two injected scripts → reworded to check presence/position
independently, not relative order.
- [Commit 4, G2/G3/G4] "Env-var-gated, mirroring the CSS/JS demo pattern" was
a misleading reference (that pattern exists for optional-external-binary
gating, which doesn't apply here) → simplified to always-on in the example.
Findings accepted as tradeoffs:
- [Commit 1, G1] Large non-HTML file links get speculatively fetched once
before falling back to real navigation, absent an opt-out attribute.
Reason: a code-level heuristic (extension sniffing) would be its own
source of false negatives given mini-static's arbitrary-content-type
serving model; mitigated via documentation (Commit 4) instead of code.
Logged in Open Questions.