Skip to content

feat: hash-driven element activation for deep-linking#86

Merged
adnaan merged 5 commits intomainfrom
feat/hash-link
Apr 19, 2026
Merged

feat: hash-driven element activation for deep-linking#86
adnaan merged 5 commits intomainfrom
feat/hash-link

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 19, 2026

Summary

  • Add dom/hash-link.ts — generic framework module that synchronizes URL hash fragments with open/close state of <dialog>, [popover], and <details> elements. Uses history.pushState (not location.hash) to avoid hashchange double-activation errors. Self-guarding close handler naturally prevents replaceState during popstate-triggered closes without boolean flags.
  • Extend invoker polyfill (dom/invoker-polyfill.ts) to handle show-popover, hide-popover, and toggle-popover commands on [popover] elements.
  • Modify link interceptor (dom/link-interceptor.ts) to intercept <a href="#id"> clicks when the target is an activatable element, while respecting shouldSkip checks (target="_blank", download, lvt-nav:no-intercept).
  • Add morphdom guard in livetemplate-client.ts to preserve open <dialog> and [popover] elements (browser top-layer state has no DOM representation).
  • Wire setupHashLink/teardownHashLink/openFromHash into connect/disconnect/first-render lifecycle.

Test plan

  • 33 new unit tests in tests/hash-link.test.ts covering openFromHash, isHashLinkTarget, activateHashTarget, invoker button clicks, close/toggle events, popstate reconciliation, teardown idempotency, and setup idempotency
  • 8 new/updated tests in tests/invoker-polyfill.test.ts for popover commands
  • All 410 existing tests pass
  • Bundle compiles (77.6kb)
  • Deploy to devbox and verify: #new-session-dialog opens dialog, reload persists, Back closes, Escape clears hash
  • Run e2e tests via ./scripts/e2e-remote.sh

🤖 Generated with Claude Code

Synchronize URL hash fragment with open/close state of <dialog>,
[popover], and <details> elements. When the hash matches an element's
ID the element is activated; when it deactivates the hash is cleared.
Supports invoker buttons, <a href="#id"> links, and Back/Forward via
popstate. Also extends the invoker polyfill with popover commands and
adds a morphdom guard to preserve open dialog/popover top-layer state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 19, 2026 16:17
@claude
Copy link
Copy Markdown

claude Bot commented Apr 19, 2026

Review

Overall this is well-structured with good tests. Two issues worth addressing:

Bug: missing try/catch in handlePopstate

hash-link.ts wraps :popover-open in try/catch inside the isOpen handler, but handlePopstate calls el.matches(":popover-open") directly without the same protection:

// handlePopstate — will throw SyntaxError in older browsers:
el.matches(":popover-open") && el.id !== id

Back/forward navigation will crash in any browser that doesn't support the Popover API. Should use the same try/catch pattern, or extract safeMatchesPopoverOpen (already defined in livetemplate-client.ts) into hash-link.ts and share it.

Behavioral gap: handlePopstate doesn't close open <details> elements

When the user navigates back/forward, handlePopstate closes open dialogs and popovers, but never closes open <details> elements that don't match the new hash. A <details> opened via hash-link will stay open after the user navigates away. The handler should mirror the same pattern used for dialogs/popovers.

Minor: double openFromHash call

setupHashLink() already calls openFromHash() internally, and livetemplate-client.ts calls it again explicitly after isInitialized = true. The guard in openFromHash prevents double-activation so this is benign — but worth confirming the second call is intentional (e.g. re-open after morphdom re-render) and adding a comment if so.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds hash-driven “deep link” activation so URL fragments can open/close <dialog>, [popover], and <details> elements and stay in sync with browser history, integrating this behavior into LiveTemplate’s navigation and DOM morphing lifecycle.

Changes:

  • Introduces dom/hash-link.ts to synchronize location.hash with activatable element open/close state using history.pushState/replaceState, plus lifecycle wiring in livetemplate-client.ts.
  • Extends the invoker polyfill to support popover commands (show-popover, hide-popover, toggle-popover) and adds/updates unit tests.
  • Updates link interception to treat same-page hash links as “activations” when the hash points at an activatable element, and adds a morphdom guard to preserve top-layer state for open dialogs/popovers.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
dom/hash-link.ts New module implementing hash↔UI synchronization for dialogs/popovers/details.
dom/invoker-polyfill.ts Adds popover command handling to the invoker polyfill.
dom/link-interceptor.ts Intercepts same-page hash links to activatable targets and activates them.
livetemplate-client.ts Wires hash-link setup/teardown/opening into client lifecycle; adds morphdom guard for top-layer elements.
tests/hash-link.test.ts New unit tests for hash-link module behaviors and lifecycle idempotency.
tests/invoker-polyfill.test.ts Adds tests for popover command support and command/target mismatches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread dom/hash-link.ts
Comment on lines +98 to +139
function handleToggle(e: Event): void {
const el = e.target;
if (!(el instanceof Element)) return;
if (!el.id) return;

const handler = findHandler(el);
if (!handler) return;

if (handler.isOpen(el)) {
if (location.hash === "#" + el.id) return;
history.pushState(null, "", "#" + el.id);
} else {
if (location.hash !== "#" + el.id) return;
history.replaceState(null, "", location.pathname + location.search);
}
}

function handlePopstate(): void {
const id = location.hash.slice(1);

document.querySelectorAll("dialog[open]").forEach((el) => {
if (el.id !== id) (el as HTMLDialogElement).close();
});
document
.querySelectorAll("[popover]")
.forEach((el) => {
if (
el instanceof HTMLElement &&
el.matches(":popover-open") &&
el.id !== id
) {
el.hidePopover();
}
});

if (id) {
const el = document.getElementById(id);
if (el) {
const handler = findHandler(el);
if (handler && !handler.isOpen(el)) handler.open(el);
}
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The new hash-link behavior includes popover synchronization via toggle/popstate (push hash on open, clear on close, and close non-matching open popovers on back/forward), but the added unit tests only cover invoker-button clicks for popovers. Add tests that simulate popover toggle events and popstate-driven popover closes to ensure this synchronization works (and stays working) across changes.

Copilot uses AI. Check for mistakes.
Comment thread dom/invoker-polyfill.ts Outdated
Comment on lines +33 to +38
if (command === "show-popover") {
target.showPopover();
} else if (command === "hide-popover") {
target.hidePopover();
} else if (command === "toggle-popover") {
target.togglePopover();
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

Popover command handling calls showPopover()/hidePopover()/togglePopover() without checking that those methods exist. On browsers that lack the Popover API but still have elements with a popover attribute in the DOM, this will throw and break all delegated clicks. Add a typeof (target as any).showPopover === "function" (etc.) guard (and optionally an open-state check) before invoking these methods, otherwise no-op like the dialog branch does.

Suggested change
if (command === "show-popover") {
target.showPopover();
} else if (command === "hide-popover") {
target.hidePopover();
} else if (command === "toggle-popover") {
target.togglePopover();
const popoverTarget = target as HTMLElement & {
showPopover?: () => void;
hidePopover?: () => void;
togglePopover?: () => void;
};
const isOpen = target.matches(":popover-open");
if (command === "show-popover") {
if (typeof popoverTarget.showPopover === "function" && !isOpen) {
popoverTarget.showPopover();
}
} else if (command === "hide-popover") {
if (typeof popoverTarget.hidePopover === "function" && isOpen) {
popoverTarget.hidePopover();
}
} else if (command === "toggle-popover") {
if (typeof popoverTarget.togglePopover === "function") {
popoverTarget.togglePopover();
}

Copilot uses AI. Check for mistakes.
Comment thread dom/hash-link.ts
Comment on lines +27 to +39
{
matches: (el) =>
el instanceof HTMLElement && el.hasAttribute("popover"),
isOpen: (el) => {
try {
return (el as HTMLElement).matches(":popover-open");
} catch {
return false;
}
},
open: (el) => (el as HTMLElement).showPopover(),
close: (el) => (el as HTMLElement).hidePopover(),
},
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The popover handler assumes the Popover API exists whenever an element has a popover attribute, and unconditionally calls showPopover()/hidePopover(). If showPopover/hidePopover are undefined (older browsers, partial polyfills), this will throw at runtime. Consider tightening matches (e.g., require the methods to be functions) and making open/close no-op safely when the API isn’t available.

Copilot uses AI. Check for mistakes.
Comment thread dom/hash-link.ts Outdated
Comment on lines +121 to +131
document
.querySelectorAll("[popover]")
.forEach((el) => {
if (
el instanceof HTMLElement &&
el.matches(":popover-open") &&
el.id !== id
) {
el.hidePopover();
}
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

handlePopstate uses el.matches(":popover-open") without a try/catch. In environments where the :popover-open selector is unsupported, matches() throws a SyntaxError and breaks popstate handling entirely. Use the same safe wrapper approach as isOpen (or centralize it) before calling matches, and similarly guard hidePopover() if Popover API may be missing.

Copilot uses AI. Check for mistakes.
Comment thread dom/hash-link.ts Outdated
Comment on lines +187 to +195
/** @internal Test-only — do not call from production code. */
export function teardownHashLink(): void {
if (!installed) return;
installed = false;

document.removeEventListener("click", handleClick);
document.removeEventListener("close", handleClose, true);
document.removeEventListener("toggle", handleToggle, true);
window.removeEventListener("popstate", handlePopstate);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

teardownHashLink is documented as “Test-only — do not call from production code”, but this PR wires it into the production disconnect lifecycle. Either adjust the function’s visibility/contract (and comment) to reflect production use, or avoid calling it from LiveTemplateClient so the “test-only” promise remains true.

Copilot uses AI. Check for mistakes.
Comment thread dom/link-interceptor.ts Outdated
Comment on lines +103 to +110
if (target.pathname === window.location.pathname && target.hash) {
const hashId = target.hash.slice(1);
if (hashId && isHashLinkTarget(hashId)) {
e.preventDefault();
activateHashTarget(hashId);
}
return;
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The hash-link interception checks only pathname equality. If a link changes the query string but also has a hash (e.g. /items?page=2#dlg from /items?page=1), this branch returns early and bypasses LiveTemplate navigation entirely, causing a full-page navigation instead of the intended SPA/WS navigate path. Consider requiring both pathname and search to match the current URL before treating it as a hash-only activation case.

Copilot uses AI. Check for mistakes.
Comment thread livetemplate-client.ts Outdated
Comment on lines +1215 to +1217
!(toEl as Element).hasAttribute('data-lvt-force-update') && (
(fromEl instanceof HTMLDialogElement && fromEl.hasAttribute('open')) ||
(fromEl instanceof HTMLElement && fromEl.hasAttribute('popover') && safeMatchesPopoverOpen(fromEl))
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

New code uses single quotes for attribute names ('data-lvt-force-update', 'open', 'popover') while the surrounding code in this method uses double quotes. For consistency with the rest of the file (and the repo’s predominant style), switch these to double quotes.

Suggested change
!(toEl as Element).hasAttribute('data-lvt-force-update') && (
(fromEl instanceof HTMLDialogElement && fromEl.hasAttribute('open')) ||
(fromEl instanceof HTMLElement && fromEl.hasAttribute('popover') && safeMatchesPopoverOpen(fromEl))
!(toEl as Element).hasAttribute("data-lvt-force-update") && (
(fromEl instanceof HTMLDialogElement && fromEl.hasAttribute("open")) ||
(fromEl instanceof HTMLElement && fromEl.hasAttribute("popover") && safeMatchesPopoverOpen(fromEl))

Copilot uses AI. Check for mistakes.
- Refactor handlePopstate to use handlers array, fixing missing
  try/catch for :popover-open and adding details close on back/forward
- Guard popover API calls (showPopover/hidePopover/togglePopover) for
  browsers without native support in hash-link and invoker-polyfill
- Check both pathname and search in link-interceptor hash-only path
- Fix teardownHashLink JSDoc (used in production disconnect())
- Normalize quote style in morphdom preservation guard
- Add tests for popover toggle events and popstate details close

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 19, 2026

Review

Overall: Solid feature with good test coverage and thoughtful self-guarding design. A few things worth addressing:

Bug: openFromHash() called twice on first load

setupHashLink() already calls openFromHash() internally (hash-link.ts:185). The explicit call added at livetemplate-client.ts:352 means it runs twice on initial connect. It's safe due to the isOpen guard, but if the intent is "re-apply hash after DOM updates from server" (which makes sense), a comment explaining this is needed — otherwise it looks like a duplicate.

Minor: safeMatchesPopoverOpen duplicated

The same try/catch pattern exists in hash-link.ts (handlers[1].isOpen) and as a standalone function in livetemplate-client.ts:1099. Worth extracting to a shared util or at minimum reusing the one already in hash-link.ts.

link-interceptor.ts behavior change looks correct

Replacing the shouldSkip hash guard with in-handler logic is the right call — regular scroll anchors pointing to a plain <div> still get native browser handling via the bare return without preventDefault. The added target.search === window.location.search check is a subtle tightening: same-path links with different query strings are now intercepted as navigation, which is the correct behavior the old code missed.

Edge case: server removes an open dialog from the DOM

If a server push causes morphdom to delete an element that is currently open (hash-driven dialog/popover), no close event fires, leaving a stale hash in the URL. The top-layer guard in livetemplate-client.ts only skips updates — it does not protect against removal. Worth a test or a note if this is an accepted edge case.

- Extract safeMatchesPopoverOpen to hash-link.ts as shared export,
  removing the duplicate local definition in livetemplate-client.ts
- Add comment explaining why openFromHash() is called after first
  render (setupHashLink's internal call fires before server content)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 19, 2026

Code Review

Overall this is a clean, well-tested implementation. A few things worth checking before merge:

Potential bug: handlePopstate closes all open <details> elements

In handlePopstate, when the hash changes, every open dialog/[popover]/details whose ID doesn't match the new hash is closed. For dialogs/popovers this is correct (only one can be in the top layer). But <details> elements are independent — a page with a FAQ accordion could have many open <details> at once. Navigating to #another-section would silently close all of them. Consider restricting the close-all sweep to dialogs and popovers, and leaving <details> elements alone.

Module-level installed singleton

The installed flag in hash-link.ts is module-scoped. If two LiveTemplateClient instances ever exist on the same page, the first teardownHashLink() call silently removes the listeners for both. Probably fine for the current single-instance use case, but worth a comment noting the assumption.

Minor: safeMatchesPopoverOpen leaks as a public export

It's imported in livetemplate-client.ts solely for the morphdom guard. Either mark it @internal (consistent with teardownHashLink) or move it to a shared utils file to avoid consumers accidentally depending on it.

Everything else looks good

  • Using history.pushState instead of location.hash = ... to avoid double-activation is the right call.
  • The self-guarding handleClose (checks location.hash !== "#" + el.id before clearing) cleanly avoids boolean flags.
  • The morphdom onBeforeElUpdated guard with data-lvt-force-update escape hatch is well thought out.
  • Test coverage is thorough — the popstate self-guard test is a nice edge case catch.
  • The link-interceptor.ts refactor correctly preserves scroll-anchor behavior for plain <a href="#id"> links (resolved relative URLs inherit the current search params, so target.search === window.location.search holds for bare hash hrefs).

- Details elements are independent (not top-layer), so popstate should
  not close all open details when the hash changes — only dialogs and
  popovers participate in the close-all sweep
- Mark safeMatchesPopoverOpen as @internal to prevent consumer reliance
- Update tests to reflect details-are-independent semantics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 19, 2026

Review

Bug / UX inconsistency — <details> not reconciled on popstate

handlePopstate closes open dialog and [popover] elements when the hash changes away, but skips <details>:

// hash-link.ts:119
document.querySelectorAll("dialog, [popover]").forEach((el) => {

The test "does not close open details when hash changes away" confirms this is intentional, but the behaviour is asymmetric: a user who opens a <details> via a hash link, then presses back, will see the URL cleared while the details stays expanded. Worth either extending the selector to include details[id] or adding a comment explaining why the asymmetry is correct.


Module-level singleton (installed) is fragile with multiple client instances

If two LiveTemplateClient instances are alive simultaneously (e.g. in tests or a multi-frame setup), the second call to setupHashLink() is silently no-oped because the first already set installed = true. The listeners are then tied to the first client's lifetime. Likely fine for the single-client production path, but tests that don't call teardownHashLink between cases could leak state. The test suite does call teardownHashLink in afterEach, so this is low risk today — just worth noting if multi-instance support ever lands.


safeMatchesPopoverOpen leaks an internal detail

It's marked @internal but exported from hash-link.ts and imported directly into livetemplate-client.ts for the morphdom guard. Since it's a three-line try/catch, duplicating it inline in livetemplate-client.ts (or hoisting a shared dom/popover-utils.ts) would be cleaner than exporting an internal helper across the module boundary.


Overall: the core logic is sound, the test coverage is thorough, and the link-interceptor refactor correctly preserves scroll-anchor behaviour for non-activatable hash links. The <details> popstate issue is the only one I'd confirm before merging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 19, 2026

Review

Good implementation overall. The design is clean, event guards are correct, and test coverage is thorough. A few things to flag:

Undocumented data-lvt-force-update escape hatch

The morphdom skip (return false for open dialogs/popovers) freezes all server-driven updates inside open elements. The data-lvt-force-update override is only in a code comment. Users who observe stale dialog content during live updates will have nowhere to look. This should land in a CHANGELOG or README note.

Behavioral change in link-interceptor worth documenting

Removing the shouldSkip hash guard changes behavior for links like href="?other=val#id" on a page at ?current=val: old behavior was a full page reload (interceptor skipped them), new behavior is SPA navigation. This is an improvement, but worth a note in the PR description for upgraders.

Minor: redundant hashId && guard (link-interceptor ~line 104)

target.hash.slice(1) can only be empty if target.hash is empty, which is already excluded by the outer target.hash check.

Minor: safeMatchesPopoverOpen export

Marked @internal but exported directly from hash-link.ts. Fine for same-package use, but if there are external consumers, consider inlining the try/catch in livetemplate-client.ts to avoid leaking the impl detail.

No blocking issues. The state machine, event ordering, and self-guarding close handler are solid.

@adnaan adnaan merged commit c85d36c into main Apr 19, 2026
6 checks passed
@adnaan adnaan deleted the feat/hash-link branch April 19, 2026 18:48
adnaan added a commit that referenced this pull request May 3, 2026
The server stopped emitting the "sm" (StaticsMap) field on TreeNode in
v0.8.0 when fingerprint-based statics tracking replaced the per-path
ClientStructureRegistry (#86). The client kept the read path
and the parameter chain alive as a no-op fallback, but the underlying
data has not flowed since v0.8.0 — every reachable codepath now passes
undefined.

Removed:
- types.ts: staticsMap?: Record<string, string[]> field on TargetedRangeOp.
- state/tree-renderer.ts:
  - staticsMap?: ... field on RangeStateEntry interface.
  - sm: existing.sm / value.sm / rangeStructure.sm reads (5 sites).
  - sm: ... writes into treeState and rangeState (3 sites).
  - The dead `_sk` lookup branch inside renderRangeItem.
  - staticsMap?: ... parameter from renderRangeItem and renderItemsWithStatics.
- state/range-dom-applier.ts:
  - staticsMap parameter from RenderItemFn type and 6 internal helpers
    (apply, applyUpdateRow, applyInsertAfter, applyAppend, applyPrepend,
    renderItemsAtomic, renderAndParse).
  - Destructuring at apply() entry point.
- livetemplate-client.ts: dropped sm arg in the renderItem callback wiring.
- tests/range-dom-applier.test.ts: updated test renderItem callback to
  match the new 4-arg signature.

No tests referenced StaticsMap, _sk, or "sm" — confirming the path was
unreachable from any runtime scenario before this commit. All 529 tests
pass; tsc --noEmit clean.

Closes #17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adnaan added a commit that referenced this pull request May 3, 2026
The server stopped emitting the "sm" (StaticsMap) field on TreeNode in
v0.8.0 when fingerprint-based statics tracking replaced the per-path
ClientStructureRegistry (#86). The client kept the read path
and the parameter chain alive as a no-op fallback, but the underlying
data has not flowed since v0.8.0 — every reachable codepath now passes
undefined.

Removed:
- types.ts: staticsMap?: Record<string, string[]> field on TargetedRangeOp.
- state/tree-renderer.ts:
  - staticsMap?: ... field on RangeStateEntry interface.
  - sm: existing.sm / value.sm / rangeStructure.sm reads (5 sites).
  - sm: ... writes into treeState and rangeState (3 sites).
  - The dead `_sk` lookup branch inside renderRangeItem.
  - staticsMap?: ... parameter from renderRangeItem and renderItemsWithStatics.
- state/range-dom-applier.ts:
  - staticsMap parameter from RenderItemFn type and 6 internal helpers
    (apply, applyUpdateRow, applyInsertAfter, applyAppend, applyPrepend,
    renderItemsAtomic, renderAndParse).
  - Destructuring at apply() entry point.
- livetemplate-client.ts: dropped sm arg in the renderItem callback wiring.
- tests/range-dom-applier.test.ts: updated test renderItem callback to
  match the new 4-arg signature.

No tests referenced StaticsMap, _sk, or "sm" — confirming the path was
unreachable from any runtime scenario before this commit. All 529 tests
pass; tsc --noEmit clean.

Closes #17.

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.

2 participants