Skip to content

fix: full reload when SPA navigation crosses an app boundary#119

Merged
adnaan merged 2 commits intomainfrom
fix/cross-app-head-mismatch
May 6, 2026
Merged

fix: full reload when SPA navigation crosses an app boundary#119
adnaan merged 2 commits intomainfrom
fix/cross-app-head-mismatch

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented May 6, 2026

Summary

LinkInterceptor's SPA optimization patches the body and updates <title> while leaving <head> alone. That's correct for navigating between routes within one app, but breaks when the destination is a different app whose <head> declares different stylesheets — the new body ends up styled by the previous app's CSS.

handleNavigationResponse now compares the set of <link rel="stylesheet"> URLs in the fetched HTML to those in the live document. Different sets ⇒ window.location.reload() to force a real navigation. Same sets ⇒ proceed on the swap path as before.

How this was reproduced

Live on livetemplate.fly.dev (the LiveTemplate docs site, which reverse-proxies lt-patterns.fly.dev at /patterns/*):

  1. Open /patterns/forms/click-to-edit (proxied content from the patterns app)
  2. Click the Patterns link in the header — href=\"/\"
  3. Same-origin pathname change ⇒ LinkInterceptor fetches /, swaps the body
  4. <head> still has the patterns app's pico.min.css + livetemplate.css; the docs site's pico.css + tinkerdown-client.css + prism.css are gone
  5. Sidebar collapses to inline flow, layout grid breaks
  6. Refresh fixes it (full document load)

Reproduced in chromedp:

Fresh load of /:    sb.position=fixed, externalCSS=[pico.css, tinkerdown-client.css, prism.css]
After click flow:   sb.position=STATIC, externalCSS=[pico.min.css, livetemplate.css]

Test plan

  • New cross-app boundary describe block in tests/navigation.test.ts:
    • Different stylesheets ⇒ body swap path NOT taken (disconnect not called, wrapper content preserved — guarantees no half-swapped state)
    • Matching stylesheets ⇒ body swap proceeds normally
  • Full client suite green: 521/521 tests pass

Why this heuristic

Stylesheet-href set is a noisy-but-effective proxy for "different <head>":

  • Apps deployed independently almost always link different stylesheet bundles, even when both use Pico/etc — the URLs differ (one app's CDN vs another's /assets/)
  • Routes within one app share a <head> template, so the set is identical
  • Cheap to compute (one querySelectorAll + Set comparison)

Other signals considered and rejected:

  • Compare every <head> child node ⇒ too noisy (transient <style> blocks added by LiveTemplate)
  • Compare <title> ⇒ no signal; titles change within an app
  • Whitelist same-handler-id ⇒ already the existing path; cross-app can have the same handler id by collision

Notes

This is an additive guard. The optimization path is unchanged for any navigation where it's actually safe.

🤖 Generated with Claude Code

LinkInterceptor optimistically swaps body content while keeping the
head intact when navigating between same-origin pages. That works for
intra-app navigation (different routes within one LiveTemplate handler)
but produces silently-broken renders when the destination is a
different app entirely — the docs body lands in a page whose head
still references the previous app's stylesheets.

Reproduced live on livetemplate.fly.dev: the docs site reverse-proxies
a separately-deployed pattern showcase at /patterns/*. Clicking the
"Patterns" link in the proxied app's header (href="/") fetches the
docs root, swaps the body, and leaves the head pointing at the
showcase's pico.min.css + livetemplate.css. The result is the docs
HTML rendered with the wrong stylesheet — sidebar collapses to inline
flow, layout grid breaks, "fixed by reload" because reload pulls a
fresh head.

handleNavigationResponse now compares the set of <link rel="stylesheet">
hrefs in the fetched HTML against those in the live document. If they
differ — the heuristic that catches a cross-app boundary — fall through
to window.location.reload() so the browser performs a real navigation
that loads the complete document. Same-stylesheet navigation continues
on the optimized swap path.

Tests cover both directions: cross-app stylesheets ⇒ body swap path
short-circuited (disconnect not called, wrapper content preserved);
matching stylesheets ⇒ swap path proceeds normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 18:27
@claude
Copy link
Copy Markdown

claude Bot commented May 6, 2026

Good fix for a real bug. The heuristic is well-reasoned and the test coverage is solid. A few actionable issues:

1. window.location.reload() is fragile — should be window.location.href = destinationUrl (medium)

reload() works here only because LinkInterceptor.navigate already updated window.location to the destination before handleNavigationResponse is called. That implicit ordering isn't obvious, and if it ever changes the reload silently navigates to the wrong URL. Using window.location.href = destinationUrl (if the destination URL is threaded through) would be explicit and self-documenting. Alternatively, add a comment here explaining why reload() is equivalent to navigating to the destination.

2. baseHref = window.location.href resolves the fetched doc's relative URLs against the current URL (low)

In stylesheetsDiffer, the same baseHref is used to resolve relative stylesheet hrefs in both the live document and the fetched document. For the fetched doc, relative hrefs should be resolved against the destination URL, not the current URL. This works today only because (a) the URL is already updated by the time this runs and (b) production apps use root-relative or absolute stylesheet URLs. If the base ever matters (e.g. href="app.css" on a deep path), comparisons could produce a false positive.

A cleaner signature: stylesheetsDiffer(doc: Document, destinationHref: string): boolean — pass baseHref for the fetched doc explicitly, use document.baseURI or window.location.href for the live doc.

3. window.location.reload() is completely untested (low)

The test comment is honest about the jsdom limitation. The current test only verifies the early-return (disconnect not called), not that a reload is actually triggered. Consider extracting the reload into a thin wrapper (e.g. private performFullNavigation(url: string) { window.location.href = url; }) that can be spied on — this would also fix issue #1.

4. l.getAttribute("href") vs l.href for live-DOM nodes (nitpick)

For links already parsed into the live document, l.href returns the browser-resolved absolute URL directly, making the new URL(raw, baseHref) step unnecessary for the collect(document) call. The current approach is not wrong (root-relative hrefs resolve correctly), but .href is more direct and avoids the try/catch for that branch.


Overall: the logic is correct, the tests cover the important paths, and the PR description is excellent. Issues 1–2 are worth addressing before merge; 3 is nice-to-have; 4 is a nitpick.

- Replace window.location.reload() with explicit window.location.href
  via a private performFullNavigation(destinationHref) wrapper. The
  previous code worked by relying on link-interceptor's pushState
  having already updated window.location to the destination — that
  ordering invariant wasn't obvious and would silently navigate to
  the wrong URL if it ever changed. The wrapper also makes the path
  spy-able so the cross-app test can assert the destination.
- Thread destinationHref through handleNavigationResponse and into
  stylesheetsDiffer, then use it as the base for resolving the
  fetched doc's relative stylesheet URLs. Previously the live doc's
  base was used for both, which is correct only because production
  apps use absolute or root-relative stylesheet URLs.
- For the live doc's stylesheets, use HTMLLinkElement.href directly
  instead of getAttribute("href") + URL resolution. The browser-
  resolved value is already absolute and avoids the try/catch.

The cross-app boundary test now asserts performFullNavigation was
called with the exact destination URL, closing the loop on what was
previously an untestable reload() side effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented May 6, 2026

Good fix for a real problem. A few things worth addressing:

Bug in PR description
The summary says the fallback uses window.location.reload(), but the implementation correctly uses window.location.href = destinationHref. reload() would re-request the current URL, not navigate to the destination — the code is right, the description is wrong. Worth updating so future readers aren't confused.

Breaking interface change
LinkInterceptorContext is an exported interface. Adding a required second parameter to handleNavigationResponse is a breaking change for any external implementors. If there are downstream consumers, consider making it optional with a default (destinationHref: string = window.location.href) or bumping semver accordingly.

stylesheetsDiffer doesn't short-circuit

current.forEach((href) => {
  if (!fetched.has(href)) differs = true;
});

forEach can't break early. For pages with many stylesheets this iterates the whole set even after finding a mismatch. A for...of loop with return true is cleaner and avoids the mutable differs flag:

for (const href of current) {
  if (!fetched.has(href)) return true;
}
return false;

Potential false positive: <link> attributes beyond href
rel can be a space-separated token list — rel="preload stylesheet" would be included by link[rel~="stylesheet"][href], which might be intentional but could match preload hints that aren't applied stylesheets. Unlikely to matter in practice but worth a comment if it's deliberate.

Overall: Logic is sound, the performFullNavigation indirection for testability is a good call, and the test coverage is solid. Main ask is fixing the PR description and deciding on the interface semver question.

@adnaan adnaan merged commit 067827a into main May 6, 2026
6 checks passed
@adnaan adnaan deleted the fix/cross-app-head-mismatch branch May 6, 2026 18:37
adnaan added a commit to livetemplate/examples that referenced this pull request May 6, 2026
Pinning to the exact version that ships livetemplate/client#119 (full
reload when SPA navigation crosses an app boundary). The patterns app
loads via the docs site's reverse proxy, so its client library was the
one intercepting the cross-app link click and patching the docs body
into a page whose head still pointed at the patterns app's stylesheets.

Pinning (vs. @latest) is intentional here so the fix is live the moment
this branch deploys, instead of waiting for jsdelivr's edge cache on
@latest to rotate. Once the dust settles we can revert to @latest in a
follow-up to keep getting client patches automatically.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adnaan added a commit that referenced this pull request May 6, 2026
PR #119 placed the cross-app stylesheet check inside the
if (newWrapper) branch of handleNavigationResponse, where newWrapper is
the [data-lvt-id] element parsed from the fetched HTML. When the
destination is a static page that has no [data-lvt-id] anywhere — like
a tinkerdown-rendered docs root — querySelector returns null, the check
is skipped, and execution falls through to the no-wrapper body-replace
fallback at the bottom of the function. That fallback swaps body
content unconditionally, leaving the previous app's <link
rel="stylesheet"> tags in place: the exact head-vs-body mismatch the
guard is supposed to prevent.

Real-world repro: clicking the Patterns header link (href="/") from the
LiveTemplate patterns showcase navigates to the docs site root. The
patterns app loads @livetemplate/client, so LinkInterceptor fetches /,
finds no [data-lvt-id] in the response, skips the cross-app guard, and
swaps the docs body into a page whose head still references the
patterns app's pico.min.css and livetemplate.css.

Move the check before the newWrapper branch so it runs regardless of
which body-swap path the response would take.

Adds a new test case ("triggers a full navigation even when destination
has NO data-lvt-id") that pins this case directly. Also caught when the
manual chromedp repro after deploying v0.8.41 still showed the broken
state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant