Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
171ba21
feat(client): lvt-preserve attributes and __navigate__ in-band SPA nav
adnaan Apr 15, 2026
ae84769
fix(updateDOM): use DOMParser for script-containing HTML + regression…
adnaan Apr 16, 2026
0c19f00
fix: address bot review comments — case-insensitive script detection,…
adnaan Apr 16, 2026
374b357
fix: pathname guard for sameWrapper sendNavigate + replaceChildren re…
adnaan Apr 16, 2026
7787453
fix: tighten script detection regex, fix mock type, document unreacha…
adnaan Apr 16, 2026
520a015
fix: remove dead sameWrapper branch, guard sendNavigate on WS state, …
adnaan Apr 16, 2026
a0729b5
fix: correct sendNavigate WS guard condition (|| not &&), drop unused…
adnaan Apr 16, 2026
b7540ae
fix: check toEl for lvt-preserve (server can remove it), document sca…
adnaan Apr 16, 2026
f679289
fix(review): address bot review round 7 feedback
adnaan Apr 16, 2026
66e1b75
fix(review): address bot review round 8 feedback
adnaan Apr 16, 2026
bf7e4fa
fix(review): address bot review round 9 feedback
adnaan Apr 16, 2026
84be745
fix(review): address bot review round 10 feedback
adnaan Apr 16, 2026
db6b0f3
fix(review): address bot review round 11 feedback
adnaan Apr 16, 2026
88d430b
fix(review): address bot review round 12 feedback
adnaan Apr 16, 2026
1e59ba5
fix(review): address bot review round 13 feedback
adnaan Apr 16, 2026
324bf06
fix(review): address bot review round 14 feedback
adnaan Apr 16, 2026
cd51742
fix(review): address bot review round 15 feedback
adnaan Apr 16, 2026
d0e80f5
docs(review): address bot review round 16 feedback
adnaan Apr 16, 2026
903b286
fix(review): address bot review round 17 feedback
adnaan Apr 16, 2026
1fb388c
fix(review): address bot review round 18 feedback
adnaan Apr 16, 2026
830e3f3
fix(review): address bot review round 19 feedback
adnaan Apr 16, 2026
a51ef3c
fix(review): address bot review round 20 feedback
adnaan Apr 16, 2026
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to @livetemplate/client will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `lvt-preserve` attribute: morphdom escape hatch that skips an element and its subtree entirely during diff (equivalent to Phoenix LiveView's `phx-update="ignore"`). Checked on `toEl` so the server retains authority to remove it.
- `lvt-preserve-attrs` attribute: morphdom escape hatch that preserves user-managed attributes (e.g. `open` on `<details>`) while still diffing children. Protects attributes the server template does **not** set. Checked on `toEl` for consistent server authority.
- In-band `__navigate__` SPA navigation: same-pathname link clicks send `{action:"__navigate__", data:<params>}` over the existing WebSocket instead of fetching new HTML. Requires server-side support (livetemplate/livetemplate#344).
- DOMParser fallback in `updateDOM`: HTML containing `<script>` tags is now parsed via `DOMParser` to avoid a Chrome `innerHTML` bug that creates phantom duplicate DOM nodes after script tags.

### Breaking Changes

- **Cross-pathname same-handler navigation now always reconnects.** Previously, if two routes shared the same `data-lvt-id`, navigating between them would do an in-place DOM swap without reconnecting. This fast path has been removed; all cross-pathname navigations (regardless of handler ID) now trigger a full WebSocket reconnect. This is the correct behavior — same-ID across paths means two distinct routes, and `sendNavigate` cannot express a path change. **If your app shares a `data-lvt-id` across routes, expect a reconnect flash where there was none before.**

### Deployment note

The `__navigate__` in-band action is a no-op on server versions before livetemplate/livetemplate#344. Deploy the server update before or simultaneously with this client version to avoid same-pathname link clicks sending an unrecognized WebSocket action.

## [v0.8.25] - 2026-04-15

### Changes
Expand Down
79 changes: 77 additions & 2 deletions dom/link-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,30 @@ import type { Logger } from "../utils/logger";
export interface LinkInterceptorContext {
getWrapperElement(): Element | null;
handleNavigationResponse(html: string): void;
// Send an in-band navigate message over the existing WebSocket.
// Returns true if the message was sent, false if it was dropped
// (e.g. WS not open). The caller uses this to decide whether to push
// browser history state — only advancing the URL when the server will
// actually receive the navigate eliminates the TOCTOU window where
// the WS could close between canSendNavigate() and the actual send.
sendNavigate(href: string): boolean;
// Returns true when an in-band navigate message can be sent (i.e.
// WebSocket mode is active and the socket is OPEN). In HTTP mode or
// when the WS is not yet open, this is false and the same-pathname
// fast path must fall through to a normal fetch.
canSendNavigate(): boolean;
}

/**
* Intercepts <a> clicks within the LiveTemplate wrapper for SPA navigation.
* Same-origin links are fetched via fetch() and the wrapper content is replaced.
* External links, target="_blank", download, and lvt-nav:no-intercept are skipped.
*
* - Same pathname (query-string change only) -> sends __navigate__ over WS;
* no fetch, no DOM replace, no reconnect.
* - Different pathname (cross-handler or just different route) -> fetches
* new HTML and hands it to handleNavigationResponse, which decides
* between same-handler DOM replace and cross-handler reconnect.
* - External links, target="_blank", download, and lvt-nav:no-intercept
* are skipped.
*
* Uses AbortController to cancel in-flight fetches when a new navigation
* starts (rapid clicks, back/forward during fetch).
Expand Down Expand Up @@ -102,6 +120,63 @@ export class LinkInterceptor {
}

private async navigate(href: string, pushState: boolean = true): Promise<void> {
const targetURL = new URL(href, window.location.origin);
const samePath =
targetURL.origin === window.location.origin &&
targetURL.pathname === window.location.pathname;

if (samePath) {
const sameSearch = targetURL.search === window.location.search;
if (sameSearch) {
// Hash-only change or exact same URL — the browser handles scroll
// to the anchor; no server round-trip is needed. This guard also
// prevents a spurious __navigate__ from the popstate listener when
// the user navigates between same-pathname hash anchors (shouldSkip
// catches direct <a> clicks, but popstate calls navigate() directly
// and bypasses shouldSkip).
//
// Still abort any in-flight cross-path fetch: if a fetch was in
// progress when the user clicked a hash anchor, we don't want it
// to resolve and call handleNavigationResponse unexpectedly.
this.abortController?.abort();
this.abortController = null;
return;
}

if (this.context.canSendNavigate()) {
// Same-pathname, different search, WebSocket mode: use the in-band
// fast path. Before this existed, same-pathname clicks went through
// fetch + replaceChildren, which swapped DOM but left server state
// pinned to the OLD query params ("clicking any session always shows
// the first one" bug in devbox-dash).
//
// Abort any in-flight fetch even on the fast path: a user could
// click a cross-path link (starting a fetch) and quickly click a
// same-pathname link. Without aborting, the earlier fetch can
// still resolve and call handleNavigationResponse, racing with the
// in-band __navigate__ update.
this.abortController?.abort();
this.abortController = null;
// sendNavigate returns true if the WS message was actually sent.
// Push history state ONLY on success to keep window.location
// consistent with what the server received.
// If sent === false (defensive path — normally unreachable since
// canSendNavigate() already checked readyState), fall through to
// the normal fetch so the navigation isn't silently dropped.
const sent = this.context.sendNavigate(href);
if (sent) {
if (pushState) {
window.history.pushState(null, "", href);
}
return;
}
// sendNavigate returned false — fall through to fetch as recovery.
}
// HTTP mode, WS not OPEN, or sendNavigate returned false:
// fall through to normal fetch. pushState is handled downstream by
// the fetch path (after the response resolves).
}
Comment on lines 122 to +178
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same-pathname fast path returns before aborting any in-flight navigation fetch. If a user clicks a cross-path link (starting a fetch) and then quickly clicks a same-pathname link, the earlier fetch can still resolve later and call handleNavigationResponse, racing with the in-band navigate update. Consider aborting any existing abortController (and clearing it) before the fast-path return as well.

Copilot uses AI. Check for mistakes.

// Cancel any in-flight navigation fetch
this.abortController?.abort();
this.abortController = new AbortController();
Expand Down
Loading
Loading