feat: lvt-preserve attributes, __navigate__ SPA nav, DOMParser script fix#72
feat: lvt-preserve attributes, __navigate__ SPA nav, DOMParser script fix#72
Conversation
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 (<details open>) 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 <details open> 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:<params>} 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) <noreply@anthropic.com>
… tests
## Script duplication fix (Bug 2)
When updateDOM sets tempWrapper.innerHTML on HTML containing <script>
tags, Chrome's innerHTML parser handles scripts specially and can
create phantom duplicate DOM nodes after the </script> boundary.
morphdom then patches those duplicates into the live DOM, doubling
all elements that follow scripts in the template.
This was the root cause of devbox-dash's "24 key buttons instead of
12" bug: the template had an inline scroll-to-bottom <script> and
every element after it appeared twice in chromedp's OuterHTML.
Fix: when result.html contains "<script", parse via DOMParser (which
returns a standalone document without the innerHTML script quirk)
and transfer the parsed children to tempWrapper. Falls back to
innerHTML for script-free HTML (the common case).
Note: jsdom doesn't reproduce the Chrome-specific behavior, so the
unit test passes in both branches. The regression guard for the real
browser path is the chromedp e2e suite on the box.
## Conditional slot transition test (Bug 1)
Added tests/conditional-slot-transition.test.ts to verify the tree
renderer handles slot transitions from empty string ("") to sub-tree
with statics ({"0":"text","s":["<mark>","</mark>"]}). All 3 tests
pass — the tree renderer IS correct at the applyUpdate level.
The original devbox-dash issue (ScanError not showing after Send
action) was a deployment-specific artifact: the HTML reconstruction
was correct but the DOM update didn't apply. Root cause investigation
via chromedp e2e is ongoing as a follow-up.
353 tests pass (348 + 3 conditional-slot + 2 script-duplication).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ReviewOverall this is solid work — the same-pathname navigate fast path, the DOMParser fix for script duplication, and the lvt-preserve hooks are all well-reasoned with good test coverage. A few things worth addressing: Bug: case-sensitive script tag detection The check uses a case-sensitive string match for script tags. HTML tag names are case-insensitive, so Subtle: sendNavigate uses window.location.href in handleNavigationResponse In the sameWrapper branch, Consideration: lvt-preserve as a trust boundary
Minor: test fetch mock may leak on beforeEach failure In navigate.test.ts, Core logic and tests look correct. The case-insensitive script detection is the only actual bug; the rest are hardening suggestions. |
There was a problem hiding this comment.
Pull request overview
Adds client-side primitives to improve LiveTemplate SPA navigation correctness and DOM diffing robustness, plus regression tests to lock in the new behaviors.
Changes:
- Introduces
lvt-preserveandlvt-preserve-attrshandling inmorphdom’sonBeforeElUpdatedto selectively opt elements out of diffs. - Implements same-pathname SPA navigation via an in-band
__navigate__message (bypassingfetch()and DOM replacement). - Avoids
innerHTMLparsing quirks for script-containing HTML by usingDOMParserinupdateDOM.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
livetemplate-client.ts |
Adds sendNavigate, changes same-handler nav behavior to in-band WS message, adds DOMParser parsing for script HTML, and adds preserve hooks to morphdom. |
dom/link-interceptor.ts |
Adds same-pathname fast path that pushes history + calls sendNavigate instead of fetching HTML. |
tests/navigation.test.ts |
Updates same-handler navigation expectations to assert __navigate__ message instead of DOM replacement. |
tests/navigate.test.ts |
New tests for same-pathname bypass, different-path fetch behavior, external-origin skip, and empty-query navigate. |
tests/preserve.test.ts |
New tests for lvt-preserve and lvt-preserve-attrs behavior across updates. |
tests/script-duplication.test.ts |
New regression tests for inline <script> parsing duplication issue in updateDOM. |
tests/conditional-slot-transition.test.ts |
New regression tests for tree-renderer slot structural transitions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( | ||
| fromEl.nodeType === Node.ELEMENT_NODE && | ||
| (fromEl as Element).hasAttribute("lvt-preserve-attrs") && | ||
| toEl.nodeType === Node.ELEMENT_NODE | ||
| ) { | ||
| const fromAttrs = (fromEl as Element).attributes; | ||
| const toElement = toEl as Element; | ||
| for (let i = 0; i < fromAttrs.length; i++) { | ||
| const attr = fromAttrs[i]; | ||
| if (!toElement.hasAttribute(attr.name)) { | ||
| toElement.setAttribute(attr.name, attr.value); | ||
| } | ||
| } |
There was a problem hiding this comment.
The lvt-preserve-attrs attribute-copy loop currently copies all missing attributes from fromEl to toEl, including the control attribute "lvt-preserve-attrs" itself. That means once an element has lvt-preserve-attrs, the server can no longer remove that attribute in future renders (it will be re-added on every patch). Consider skipping copying of the preserve control attributes (at least lvt-preserve-attrs / lvt-preserve) so the server can opt elements in/out over time.
| if (result.html.includes("<scr" + "ipt")) { | ||
| const parser = new DOMParser(); | ||
| const doc = parser.parseFromString( | ||
| "<div>" + result.html + "</div>", | ||
| "text/html" | ||
| ); | ||
| const root = doc.body.firstElementChild; | ||
| if (root) { | ||
| while (tempWrapper.firstChild) { | ||
| tempWrapper.removeChild(tempWrapper.firstChild); | ||
| } | ||
| while (root.firstChild) { | ||
| tempWrapper.appendChild(root.firstChild); | ||
| } |
There was a problem hiding this comment.
In the DOMParser branch, the HTML is parsed inside a hard-coded "
| private async navigate(href: string, pushState: boolean = true): Promise<void> { | ||
| // 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; | ||
| } |
There was a problem hiding this comment.
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.
… preserve-attrs self-copy, abort fast-path, href threading - Use toLowerCase() before <script> detection in updateDOM so <SCRIPT>/<Script> also routes through DOMParser (HTML tags are case-insensitive) - Wrap DOMParser parse with element.tagName instead of hard-coded <div> to avoid HTML parser re-parenting for table/option/tr content when scripts are present - Skip copying lvt-preserve and lvt-preserve-attrs control attributes in the lvt-preserve-attrs loop so the server can opt elements out in future renders - Abort any in-flight fetch before the same-pathname fast-path return to prevent a stale handleNavigationResponse from racing with the __navigate__ update - Thread original href through handleNavigationResponse(html, href) instead of re-reading window.location.href in the sameWrapper branch - Replace manual globalThis.fetch backup/restore in navigate.test.ts with jest.spyOn + mockImplementation for automatic cleanup on beforeEach failure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
test |
ReviewGood overall. A few things worth checking. Bug: cross-pathname same-handler nav silently drops the path In handleNavigationResponse, the sameWrapper branch calls sendNavigate(href) for any same-handler match. But the same-pathname fast path in navigate() already returns early, so this branch is only reachable when the pathname has changed. If two routes at different paths share a handler (same data-lvt-id), sendNavigate only ships query params and the path change is silently discarded; the server re-runs Mount against the wrong route. The comment says same-handler navigation equals query-param change on the same path, but that does not match the reachable code path. Suggested fix: add a pathname equality guard before calling sendNavigate, or fall through to reconnect when the pathname changed. Potential issue: DOMParser table element wrapping Wrapping td/tr content in a bare tr/td without surrounding table+tbody still causes browser re-parenting. The HTML spec requires those elements inside a full table structure. The intent is right but incomplete for table-cell slots. Worth a follow-up test. Minor The two while loops moving nodes from root into tempWrapper can be replaced with tempWrapper.replaceChildren(...root.childNodes), supported in all browsers that ship morphdom. The lvt-preserve and lvt-preserve-attrs logic looks correct. Skipping the control attributes in the copy loop so the server can opt elements out is the right call, and the tests cover the transition cases well. |
…factor - Add prePushPathname parameter to handleNavigationResponse so it can distinguish same-path query changes (sendNavigate) from cross-path same-handler navigation (reconnect). Previously the sameWrapper branch called sendNavigate regardless of whether the path changed, silently discarding the new pathname — the server would re-run Mount on the wrong route. prePushPathname is captured before history.pushState in link-interceptor so the original path is available after pushState updates window.location - Replace two while-loop node-move idioms with tempWrapper.replaceChildren(...Array.from(root.childNodes)) which is cleaner and supported in all browsers that ship morphdom - Add regression test for cross-pathname same-handler-ID scenario Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Review: feat: lvt-preserve attributes, navigate SPA nav, DOMParser script fix Overall the approach is solid. Three targeted issues: 1. Dead code branch in handleNavigationResponse (livetemplate-client.ts) The sameWrapper && targetPathname === prePushPathname branch (that calls this.sendNavigate(href)) can never be reached in production. The link interceptor's fast path already intercepts all same-pathname navigations and returns early — handleNavigationResponse is only called for cross-pathname fetches, so targetPathname !== prePushPathname is always true when this function is invoked. The navigation.test.ts test at line 673 exercises this path by calling handleNavigationResponse directly after history.replaceState, but that sequence cannot happen through the normal LinkInterceptor → handleNavigationResponse callsite. This is harmless today but the mismatch between the test fixture and the real call path is a future maintenance trap. Either remove the dead branch (sameWrapper with different pathname always falls through to reconnect, which is correct) or add a comment explicitly calling out that this branch is unreachable via LinkInterceptor and exists only for direct callers. 2. Type annotation wrong in navigate.test.ts let handleNavigationResponseSpy: jest.Mock<void, [string, string]>; The updated signature is (html: string, href: string, prePushPathname: string): void — three string parameters, not two. The mock type should be jest.Mock<void, [string, string, string]>. This won't cause a runtime failure but will silently allow the spy to be called with fewer args without TypeScript catching it. 3. '<script' string match can produce false positives if (result.html.toLowerCase().includes('<script')) This fires on '<script' appearing anywhere in the string — including inside HTML attribute values (e.g. data-content="<script") or HTML comments (). DOMParser is always safe, so the only cost is unnecessary allocations on those false positives. Checking with /<script[\s>]/i would be more precise. The lvt-preserve/lvt-preserve-attrs attribute-copy loop, the AbortController abort-on-same-pathname-fast-path, and the DOMParser wrap-tag matching are all well thought out. No security issues found. |
…ble branch
- Use /<script[\s>]/i instead of .toLowerCase().includes('<script') to
avoid false positives from '<script' appearing inside attribute values
or HTML comments; the regex matches <script>, <script type="...">, and
<SCRIPT> without firing on data-content="<script..." strings
- Fix jest.Mock type in navigate.test.ts: handleNavigationResponseSpy was
typed as Mock<void, [string, string]> but the updated signature takes
three strings (html, href, prePushPathname) — corrected to [string, string, string]
- Add a comment on the targetPathname === prePushPathname branch in
handleNavigationResponse explaining it is structurally unreachable via
LinkInterceptor (fast path handles same-pathname navigations before a
fetch is issued) but exists as a correct fallback for direct callers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good overall — the fast-path bypass, lvt-preserve attributes, and DOMParser fix are well-motivated and the test coverage is thorough. A few things worth addressing: Bug: Dead code in DOMParser wrap breaks for const wrapTag = element.tagName.toLowerCase();
const doc = parser.parseFromString(`<${wrapTag}>${result.html}</${wrapTag}>`, 'text/html');
const root = doc.body.firstElementChild; // wrong if wrapTag === 'body'If Minor: behavioral change for same-handler, different-pathname is implicit |
…body/html DOMParser guard Remove the sameWrapper + sendNavigate block from handleNavigationResponse: - handleNavigationResponse is only reachable via LinkInterceptor for cross-pathname fetches; same-pathname links are caught by the fast path and handled via sendNavigate() directly (no fetch, no call here) - The sameWrapper branch was dead code: any same-handler-ID match in handleNavigationResponse means two different paths share an ID, and the correct response is a full reconnect (not sendNavigate which silently drops the path). Fall-through to newWrapper handles this correctly - Remove prePushPathname from the LinkInterceptorContext interface and related call sites — no longer needed without the sameWrapper guard - Update navigation.test.ts: replace the two same-handler tests with one that verifies the reconnect path (not sendNavigate) for same-ID cross- path responses; same-pathname coverage lives in navigate.test.ts Guard sendNavigate against disconnected WebSocket: - Update liveUrlOverride + webSocketManager.setLiveUrl() on every sendNavigate so any subsequent reconnect uses the new query params - If the WebSocket is not OPEN and not in HTTP mode, skip send() and let the pending reconnect deliver the correct state via the updated liveUrl - __navigate__ is a WebSocket-only in-band action; HTTP fallback is not appropriate here Add body/html guard in DOMParser wrapping: - Wrapping with wrapTag=body/html causes doc.body.firstElementChild to return the first child rather than the wrapper, stripping content - Fall back to div for body/html elements (defensive; updateDOM is called on the lvt wrapper div in practice) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood feature set overall — the Bug:
|
… href param - Fix inverted boolean logic in sendNavigate: the guard was `getReadyState() !== 1 && !useHTTP` which allowed the HTTP fallback to POST __navigate__ when useHTTP=true (the condition evaluated false, falling through to send()). The correct guard is `getReadyState() !== 1 || useHTTP` — skip in both the not-open-WS case and the HTTP-mode case, since __navigate__ is WebSocket-only - Drop the unused `href` parameter from handleNavigationResponse and its interface, binding, and test helper — the parameter was carried over from the sameWrapper branch which was removed last commit - Update handleNavigationResponseSpy mock type to [string] (one arg) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good work overall — the three features are well-motivated and the test coverage is solid. A few issues worth addressing before merge: Bug:
The fix is to check Silent navigation loss when WebSocket is mid-reconnect In Consider either (a) deferring Multi-value query params silently collapsed url.searchParams.forEach((v, k) => { data[k] = v; });
Minor: magic number for this.webSocketManager.getReadyState() !== 1 /* WebSocket.OPEN */The comment is fine, but if |
…lar param constraint - Change lvt-preserve check from fromEl to toEl in onBeforeElUpdated. Checking fromEl made the attribute sticky once in the DOM — the server could never remove it in a later render. Checking toEl gives the server authority: if a subsequent template omits lvt-preserve, morphdom resumes updating the element. This matches how Phoenix LiveView phx-update="ignore" works and is consistent with the lvt-preserve-attrs implementation. Add regression test verifying server removal via a new full template. - Document the scalar-param constraint in sendNavigate: duplicate keys in URLSearchParams are last-write-wins into Record<string,string>. Routes using repeated params should not rely on sendNavigate directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood additions overall — the motivation and edge-case handling are clearly documented. A few things worth addressing. Bug: DOMParser fallback re-introduces the script-duplication bug In const root = doc.body.firstElementChild;
if (root) {
tempWrapper.replaceChildren(...Array.from(root.childNodes));
} else {
tempWrapper.innerHTML = result.html; // fallback reintroduces the bug
}This path is only reached when the HTML contains Behavioural regression: cross-pathname same-handler no longer does in-place DOM swap The old
When the WebSocket is not OPEN, Minor: The guard |
- Upgrade sendNavigate WS-CLOSED path from warn to error so a dropped __navigate__ (autoReconnect disabled) is visible in logs - DOMParser fallback now uses doc.body.childNodes instead of innerHTML, preserving the script-duplication fix even when the wrapper-root heuristic yields null - Add inline comment on lvt-preserve-attrs limitation: only missing attributes are copied from fromEl; server-provided attributes still win, so JS-only mutations (classList.add) can be overwritten All 357 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood, well-documented PR with solid test coverage. Three concerns worth addressing: Bug risk:
|
- Switch lvt-preserve-attrs check from fromEl to toEl, giving server authority to remove the attribute immediately on the next render (consistent with lvt-preserve's toEl check) - Warn when sendNavigate receives duplicate query param keys so silent data loss is visible in logs - Correct misleading regex comment: /<script[\s>]/i avoids "noscript" style false positives but can still fire on <script inside attribute values; false positive routes through DOMParser harmlessly All 357 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood overall — the same-pathname fast path, Bug: In Suggest: move Behavior change: same-ID cross-pathname now always reconnects
Minor: magic WebSocket readyState literal
The comment correctly notes that JS-added classes are overwritten when the server template also sets The three new test files ( |
- sendNavigate warning now explicitly names the URL/state desync when WS is not open: browser URL has advanced via pushState but server state lags; liveUrl update means reconnect self-heals it - Update PR description to document the cross-pathname same-handler reconnect behavioral change (no fast-path in-place DOM swap) All 357 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood overall — the same-pathname fast path, Potential bug: stale
|
- Clarify liveUrlOverride is never stale: pushState runs before handleNavigationResponse so window.location is always the final target - Upgrade sendNavigate to error-level for HTTP mode (no WS reconnect self-healing path) and WS CLOSED; keep warn for mid-reconnect only - lvt-preserve-attrs attribute copy now uses setAttributeNS/hasAttributeNS to preserve namespace info for SVG xlink:href etc. All 357 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good set of primitives. A few things worth addressing before merge: Bug: HTTP mode + same-pathname fast path causes permanent URL desyncIn The // LinkInterceptorContext
canSendNavigate(): boolean; // returns false when useHTTP is true
// link-interceptor.ts fast path
if (targetURL.pathname === window.location.pathname && this.context.canSendNavigate()) {
if (pushState) window.history.pushState(null, "", href);
this.context.sendNavigate(href);
return;
}
// else: fall through to fetch path normallyBreaking change: same-handler cross-pathname now always reconnectsThe old fast path ( Minor:
|
- Add canSendNavigate() to LinkInterceptorContext so the same-pathname fast path is skipped in HTTP mode. Previously pushState fired before sendNavigate which then bailed, leaving the URL permanently ahead of server state with no recovery path. - Implement canSendNavigate as !useHTTP in livetemplate-client.ts - Add regression test: HTTP mode same-pathname click must use fetch All 358 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good PR overall — the three primitives are well-motivated and the test coverage is solid. A few things worth addressing before merge: 1. Hash-only navigation triggers a spurious The fast-path condition in targetURL.pathname === window.location.pathname &&
this.context.canSendNavigate()A link like 2. Duplicate query-param silent data loss (minor but latent) The 3. Mid-reconnect CONNECTING state is not fully self-healing (edge case) When Non-blocking:
|
- Fix hash-only navigation spurious __navigate__: navigate() now checks
sameSearch before entering the sendNavigate fast path. The popstate
listener calls navigate() directly (bypassing shouldSkip), so without
this guard a hash-anchor popstate fired sendNavigate with data={}
- Add regression test: hash-only popstate must not trigger sendNavigate
or fetch (simulates popstate → navigate() directly)
- Add CONNECTING-state comment explaining the edge case where in-progress
handshake can complete at old URL before reconnect fires
All 359 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good set of changes — well-commented and the test coverage is thorough. Two issues worth addressing before merge: 1. Dead code in
// livetemplate-client.ts ~line 598
if (this.webSocketManager.getReadyState() !== 1 || this.useHTTP) {
if (this.useHTTP) { // ← dead: caller guarantees !this.useHTTP
this.logger.error(...)
}Either remove the 2. Race: hash-only navigation doesn't abort in-flight fetches The early-return for // link-interceptor.ts ~line 127
if (sameSearch) {
return; // ← abortController is never aborted
}Scenario: user clicks a cross-path link → Fix: abort before returning. if (sameSearch) {
this.abortController?.abort();
this.abortController = null;
return;
}Everything else looks solid: the |
- Remove dead HTTP mode branch from sendNavigate: caller (LinkInterceptor) only calls this when canSendNavigate()=true (!useHTTP), so the branch was unreachable. Add comment documenting the invariant instead. - Abort in-flight cross-path fetch when hash-only navigation exits early: without this, a pending fetch could resolve and replace the DOM after the user intended to stay on the same page. All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good set of additions overall — the test coverage is thorough and the comments are unusually clear about intent. A few things worth addressing: 1. sendNavigate drop is unrecoverable with the default config
2. HTTP-mode same-pathname fallthrough now triggers a reconnect attempt The old 3. lvt-preserve-attrs silently overwrites JS-added classes The implementation copies only attributes missing from Minor: the |
- sendNavigate now checks autoReconnect (defaults false) to decide log severity for CONNECTING/CLOSING states: error when autoReconnect is off (may be unrecoverable) vs warn when it's on (self-healing) - handleNavigationResponse skips connect() in HTTP mode to prevent unexpected WebSocket creation; DOM swap + event re-setup is sufficient for HTTP-mode apps All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Review: feat: lvt-preserve attributes, navigate SPA nav, DOMParser script fix Overall the PR is well-reasoned and well-documented. Three targeted issues below. 1.
|
- canSendNavigate now checks WS readyState === 1 (OPEN) in addition to !useHTTP. When WS is CONNECTING or CLOSED, the interceptor falls through to the normal fetch path (pushState fires after fetch, not before) — eliminating the URL/server desync window entirely rather than logging an error about it. Issues 2 and 3 from round 15 are already documented in code and PR. All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewOverall this is solid and well-tested. Looking at the final diff, most of the concerns raised in earlier rounds are addressed. A few remaining items: 1. Server-client deployment coupling
2. Missing test for
3. Breaking behavioral change for same-ID cross-pathname navigation The removal of the No blocking issues. The DOMParser path, abort-on-fast-path race, |
- Add [Unreleased] CHANGELOG section documenting the breaking change (cross-pathname same-handler now reconnects) and the __navigate__ rollout order requirement - Update PR description with rollout note and test count No code changes; all 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Overall this is a well-structured PR. A few things worth flagging: Bug: pushState fires before the readyState re-check in sendNavigate. In link-interceptor.ts, the same-pathname WS fast path calls pushState then sendNavigate(). If the WebSocket closes between canSendNavigate() returning true and sendNavigate() re-checking readyState, the browser URL advances but the message is dropped. With autoReconnect: false (the default) there is no automatic recovery. Consider returning success/failure from sendNavigate so the interceptor can roll back the URL on failure. Nit: seenKeys Set in sendNavigate. The duplicate-key warning is good, but the forEach + Set pattern is verbose. Object.fromEntries(url.searchParams) already last-write-wins; keep the loop only if you want to emit the warning. Minor: HTTP-mode same-pathname fallthrough comment. The comment says 'skipping pushState until after fetch is not easy here', implying pushState is absent in HTTP mode. It is handled downstream by the normal fetch path -- worth clarifying so future readers do not assume there is a missing pushState. Looks good: Checking toEl (not fromEl) for lvt-preserve and lvt-preserve-attrs is correct (server retains authority to remove the attributes, verified by the new tests). The script-tag regex avoids noscript false-positives. The attribute-copy loop correctly skips the lvt-preserve control attrs so they cannot become sticky. Removing the same-handler DOM-swap fast path for cross-pathname same-ID nav (always reconnect now) is the right fix. The navigate rollout note in the CHANGELOG is a good heads-up for deployers. |
- sendNavigate now returns bool: true if the WS message was sent, false if dropped. LinkInterceptor pushes history state ONLY on true, closing the TOCTOU window where the WS could close between canSendNavigate() and the actual send (which would advance the URL with no server effect). - Clean up HTTP-mode comment: clarify pushState is handled downstream by the normal fetch path, not absent. All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Good overall — logic is sound, comments clearly document the design decisions, and test coverage is solid. A few things worth flagging:
// sendNavigate(), ~line 598
this.liveUrlOverride = newLiveUrl;
this.webSocketManager.setLiveUrl(newLiveUrl);
if (this.webSocketManager.getReadyState() !== 1) {
// ...
return false; // no pushState, no message sent
}When Consider either reverting
Skipping DOMParser fallback — no issues
Breaking change The removal of the same-ID fast path is well-justified and well-documented. The CHANGELOG note about No security concerns — DOMParser doesn't execute inline scripts; no injection vectors visible. |
- sendNavigate saves previousLiveUrl before updating liveUrlOverride and reverts both liveUrlOverride and webSocketManager.setLiveUrl on false return, so liveUrlOverride stays consistent with window.location when the message was not sent (browser URL did not change). All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewOverall the implementation is solid - well-documented, good test coverage, and the three features are independently motivated. A few things worth flagging: Bug risk: In Unguarded back-navigation after No test covers the browser back button going back past a Minor: magic number for WebSocket.OPEN
Rollout sequencing CHANGELOG notes the server must be deployed before or simultaneously with this client. Worth confirming whether the server treats an unrecognised |
- Move liveUrlOverride update to AFTER the readyState guard in sendNavigate. Since JS is single-threaded, the TOCTOU window is theoretical — but moving the update eliminates the save/revert complexity entirely: liveUrlOverride only advances when the WS message is actually sent, which is cleaner and easier to reason about. All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood overall — the features are well-motivated and the comments/tests are thorough. A few issues worth addressing. Bug: silent navigation drop on WS close between
|
- Fall through to fetch when sendNavigate returns false (instead of silently returning): if canSendNavigate passed but the WS message couldn't be sent, the navigation falls back to a fetch so the user's click is not silently lost - Document lvt-preserve vs lvt-preserve-attrs priority: lvt-preserve is checked first and returns early; it always wins over lvt-preserve-attrs on the same element (full freeze > partial attribute-only freeze) All 359 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Overall: Well-structured PR with thorough test coverage (19 new tests). No security issues found. A few things worth noting:
toElement.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
Removed The old same-handler DOM-swap path is gone; all Minor: Self-closing Test coverage looks solid: hash-only popstate no-op, abort of in-flight fetches, server authority to remove |
Summary
Three related client-side primitives for real-time livetemplate apps:
lvt-preserve and lvt-preserve-attrs
Two morphdom escape hatches checked in
onBeforeElUpdated:lvt-preserve: full skip — attributes, content, children untouched. For third-party widgets.lvt-preserve-attrs: preserve own attributes but still diff children. For<details open>pickers where the toggle state is user-managed but the items inside are server-authored. Only protects attributes the server template does not set — if the server setsclass="card", JS-added classes are still overwritten.Both attributes check
toEl(the incoming server tree), giving the server authority to remove them on a later render.In-band
__navigate__messagesendNavigate(href)parses query params and sends{action:"__navigate__", data:<params>}over the existing WebSocket. Same-handler SPA navigation (same pathname, different query string) skipsfetch()entirely — no HTTP round-trip, no WebSocket reconnect. Server companion: livetemplate/livetemplate#344.Rollout order:
__navigate__is a no-op on server versions before livetemplate/livetemplate#344. Same-pathname link clicks will send an unrecognized WebSocket action on older server versions. Deploy the server update before or simultaneously with this client release.DOMParser for script-containing HTML
updateDOMnow usesDOMParserinstead ofinnerHTMLwhen the reconstructed HTML contains<script>tags. Chrome'sinnerHTMLparser handles scripts specially and can create phantom duplicate DOM nodes after the closing tag.Behavioral note: cross-pathname same-handler navigation
The previous
handleNavigationResponsehad a fast path where a samedata-lvt-idacross different pathnames would do an in-placereplaceChildren + re-setupwithout a WebSocket reconnect. That path has been removed. Cross-pathname navigation (regardless of whether the handler ID matches) now always triggers a full reconnect. This is correct because same-ID across different paths means two distinct routes share a handler —sendNavigatecan't express a path change (only query params), so only a reconnect gets the server to the right route. This is a silent behavioral change: apps with routes sharing adata-lvt-idwill now see a reconnect flash on cross-path navigation. See CHANGELOG [Unreleased] for upgrade notes.Test plan
tests/preserve.test.ts: 6 tests (open-attr preserve, control, attrs-only children-still-diff, subtree preserve, server-remove lvt-preserve, server-remove lvt-preserve-attrs)tests/navigate.test.ts: 7 tests (same-pathname bypass, different-pathname fetch, external skip, abort in-flight fetch, empty-query, HTTP mode fallthrough, hash-only popstate no-op)tests/navigation.test.ts: updated same-handler tests for new behavior; documents cross-path same-ID reconnect trade-offtests/conditional-slot-transition.test.ts: 3 tree-renderer regression teststests/script-duplication.test.ts: 3 DOMParser regression tests (including uppercase SCRIPT)🤖 Generated with Claude Code