From 171ba21e8fb9fe6cac0ebefb89161b5b09290528 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 21:30:51 +0530 Subject: [PATCH 01/22] feat(client): lvt-preserve attributes and __navigate__ in-band SPA nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related morphdom/navigation primitives that let livetemplate handlers use query params as selection state without the WebSocket reconnect dance. ## lvt-preserve and lvt-preserve-attrs Two attribute-based escape hatches checked in onBeforeElUpdated: - lvt-preserve: full skip. Don't touch attributes, content, or children. Equivalent to Phoenix LiveView's phx-update="ignore", Turbo's data-turbo-permanent, HTMX's hx-preserve. Useful for third-party widgets that mutate their own DOM. - lvt-preserve-attrs: the subtler "own attributes only" variant. Copies fromEl's attributes that are missing from toEl so morphdom's attribute diff sees them as already present and leaves them alone, then falls through to the normal child-diff path. Lets collapsible pickers (
) keep their user-toggled state while still allowing children to update from server-authored data (e.g. the `current` class on a session card that moves when the user navigates between sessions). Three tests in tests/preserve.test.ts: - preserves
across server updates - control: without the attribute, open IS clobbered - child mutations survive (for full-subtree use case) - lvt-preserve-attrs preserves open AND children still diff ## In-band __navigate__ message Add a sendNavigate(href) private method that parses href's search params and sends {action:"__navigate__", data:} over the existing WebSocket. Wire it into: - handleNavigationResponse same-handler branch: instead of replaceChildren (which left the server's connSt.state pinned to the OLD query params and caused the devbox-dash "clicking any session always shows the first one" bug), call sendNavigate and let the server's tree response drive the DOM via the normal WS message path. - LinkInterceptor.navigate: add a same-pathname early-out that skips fetch() entirely and delegates to sendNavigate() directly. Saves one HTTP round-trip per in-handler navigation and makes query-param changes feel instant. The LinkInterceptorContext interface gains a sendNavigate(href) method so the interceptor can drive the client without taking a full client reference. Server companion: livetemplate commit feat(mount): add __navigate__ action for in-band SPA navigation (adds the event-loop special case that routes __navigate__ to callMount instead of DispatchWithState). Tests: - tests/navigate.test.ts: 4 LinkInterceptor tests covering same- pathname bypass, different-pathname fetch path, external-origin skip, and empty-query navigation - tests/navigation.test.ts: updated same-handler tests to assert the new behavior (sendNavigate called, replaceChildren NOT called) All 348 tests pass (347 previous + 4 navigate + 1 new preserve variant, −3 obsolete navigation test replacements). Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/link-interceptor.ts | 40 +++++++++- livetemplate-client.ts | 118 ++++++++++++++++++++++++++-- tests/navigate.test.ts | 152 ++++++++++++++++++++++++++++++++++++ tests/navigation.test.ts | 39 +++++++++- tests/preserve.test.ts | 163 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 tests/navigate.test.ts create mode 100644 tests/preserve.test.ts diff --git a/dom/link-interceptor.ts b/dom/link-interceptor.ts index 1b15e7c..238e274 100644 --- a/dom/link-interceptor.ts +++ b/dom/link-interceptor.ts @@ -3,12 +3,24 @@ 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. + // Used by the same-pathname fast path below: rather than fetching + // new HTML and replacing DOM, the client asks the server to re-run + // Mount with the new query params. Path-level navigation (different + // pathnames) still goes through handleNavigationResponse. + sendNavigate(href: string): void; } /** * Intercepts 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). @@ -102,6 +114,30 @@ export class LinkInterceptor { } private async navigate(href: string, pushState: boolean = true): Promise { + // Same-pathname fast path: query-string changed on the same route. + // This is definitionally same-handler (the server's mux routes by + // path), so we can skip the fetch entirely and just send an in-band + // navigate message over the existing WebSocket. The server re-runs + // Mount with the new query params, sends back a tree update, and + // the normal message handler applies it via morphdom. + // + // Before this fast path existed, same-pathname clicks went through + // fetch + replaceChildren, which swapped DOM but left the server's + // connSt.state pinned to the OLD query params — producing the + // "clicking any session always shows the first one" bug in + // devbox-dash. + const targetURL = new URL(href, window.location.origin); + if ( + targetURL.origin === window.location.origin && + targetURL.pathname === window.location.pathname + ) { + if (pushState) { + window.history.pushState(null, "", href); + } + this.context.sendNavigate(href); + return; + } + // Cancel any in-flight navigation fetch this.abortController?.abort(); this.abortController = new AbortController(); diff --git a/livetemplate-client.ts b/livetemplate-client.ts index ae978a7..f112d01 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -111,7 +111,20 @@ export class LiveTemplateClient { this.options = { autoReconnect: false, // Disable autoReconnect by default to avoid connection loops reconnectDelay: 1000, - liveUrl: window.location.pathname + window.location.search, // Connect to current page (including query params) + // liveUrl captures the current page URL (path + search) so the + // initial WebSocket handshake reaches the server with the same + // query params Mount saw on the HTTP GET. This is intentional — + // without the search, the WS-side Mount re-runs with empty data + // and drifts state from the HTTP render. + // + // For *same-handler* SPA navigation (changing query params on + // the same path), the client does NOT reconnect — instead it + // sends an in-band {action:"__navigate__", data:} message + // over the existing WebSocket, and the server re-runs Mount with + // the new data. See sendNavigate() and handleNavigationResponse() + // for the SPA path. Cross-handler SPA navigation still does the + // fetch-and-replaceChildren+reconnect dance. + liveUrl: window.location.pathname + window.location.search, ...restOptions, }; @@ -189,6 +202,7 @@ export class LiveTemplateClient { { getWrapperElement: () => this.wrapperElement, handleNavigationResponse: (html: string) => this.handleNavigationResponse(html), + sendNavigate: (href: string) => this.sendNavigate(href), }, this.logger.child("LinkInterceptor") ); @@ -542,6 +556,36 @@ export class LiveTemplateClient { return this.liveUrlOverride || this.options.liveUrl || "/live"; } + /** + * Send an in-band navigate message over the existing WebSocket. + * + * This is the client side of the same-handler SPA navigation flow. + * Rather than disconnect + reconnect to land a URL change (which is + * what cross-handler nav does and what the old same-handler path + * silently skipped), we parse the target URL's query params into a + * data map and send {action: "__navigate__", data: params}. The + * server special-cases this action name in its event loop (see + * livetemplate/mount.go) and re-runs Mount with the new data without + * tearing down the connection. + * + * Equivalent to Phoenix LiveView's live_patch / handle_params: + * path-level identity for the socket, Mount-level re-projection for + * query-string changes. + * + * @param href - The target URL to navigate to. Only the search params + * are consumed; the pathname is assumed to match the + * current page (caller checks same-handler first). + */ + private sendNavigate(href: string): void { + const url = new URL(href, window.location.origin); + const data: Record = {}; + url.searchParams.forEach((v, k) => { + data[k] = v; + }); + this.logger.debug("sendNavigate", { href, data }); + this.send({ action: "__navigate__", data }); + } + /** * Send action via HTTP POST */ @@ -662,11 +706,23 @@ export class LiveTemplateClient { : null; if (sameWrapper) { - this.wrapperElement.replaceChildren( - ...Array.from(sameWrapper.childNodes).map((n) => n.cloneNode(true)) - ); - this.eventDelegator.setupEventDelegation(); - this.linkInterceptor.setup(this.wrapperElement); + // Same-handler navigation = query-param change on the same path. + // Instead of replacing the whole DOM (which would require a full + // WebSocket reconnect to keep server state in sync — the former + // behaviour silently dropped the second part and caused stale + // state bugs like devbox-dash's "always shows the second session" + // regression), we send an in-band navigate message over the + // existing WebSocket. The server's event loop intercepts the + // reserved __navigate__ action, re-runs Mount with the new query + // params, and pushes a tree update back — which the normal + // WebSocket message handler applies via morphdom. No reconnect, + // no DOM churn, no stale state. + // + // We've already parsed the fetched HTML above, but for + // same-handler nav we throw it away — the server-authored tree + // update is the source of truth and will overwrite any local DOM + // differences via morphdom. + this.sendNavigate(window.location.href); return; } @@ -942,6 +998,56 @@ export class LiveTemplateClient { } }, onBeforeElUpdated: (fromEl, toEl) => { + // Honour lvt-preserve: the client owns this element's entire + // subtree and morphdom should never touch any of it. + // Attributes, content, descendants all stay exactly as the DOM + // currently has them. Equivalent to Phoenix LiveView's + // phx-update="ignore" and Turbo's data-turbo-permanent. Useful + // for third-party widgets (maps, date pickers, charts) that + // mutate their own DOM, and for scroll containers with + // JS-managed scrollTop. + // + // For the subtler case of "preserve my *attributes* but still + // let children update" (e.g.
,