Skip to content

feat: polyfill command/commandfor for cross-browser dialog support#57

Merged
adnaan merged 4 commits intomainfrom
invoker-polyfill
Apr 11, 2026
Merged

feat: polyfill command/commandfor for cross-browser dialog support#57
adnaan merged 4 commits intomainfrom
invoker-polyfill

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 11, 2026

Summary

  • Adds a lightweight polyfill for the HTML Invoker Commands API (command/commandfor) so <button command="show-modal" commandfor="dialog-id"> works in Firefox and Safari (not just Chrome 135+)
  • The polyfill calls .showModal() / .close() on target <dialog> elements, keeping dialog open/close in Tier 1 (standard HTML, no lvt-* attributes needed)
  • Feature detection via commandForElement makes the polyfill a no-op when browsers add native support

Context

The Dialog Routing docs describe command/commandfor as a Tier 1 pattern for opening/closing <dialog>, but it only works natively in Chrome 135+. This was discovered during lvt PR #292 (14 e2e failures in CI Docker). Rather than downgrading dialog routing to Tier 2 (lvt-el:toggleAttr + data-lvt-target), this polyfill makes the documented pattern actually work cross-browser.

The existing commandfor exclusion in event-delegation.ts:136 already prevents these buttons from triggering server actions — this polyfill completes the picture by handling the client-side dialog behavior.

Test plan

  • 10 unit tests covering: show-modal, close, child element clicks, non-dialog targets, nonexistent targets, already-open/closed guards, feature detection skip, unknown commands
  • E2E test with new dialog-patterns example (separate PR in examples repo, depends on this release)
  • Verify polyfill is no-op in Chrome 135+ (feature detection)

🤖 Generated with Claude Code

The HTML Invoker Commands API (command/commandfor) enables native
<dialog> open/close but requires Chrome 135+ and is not supported in
Firefox or Safari. This adds a lightweight polyfill that calls
.showModal()/.close() on target <dialog> elements, keeping dialog
routing in Tier 1 (standard HTML, no lvt-* attributes needed).

Feature detection via commandForElement makes this a no-op when
browsers add native support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 11, 2026 02:11
@claude
Copy link
Copy Markdown

claude bot commented Apr 11, 2026

Good polyfill — clean implementation with solid test coverage. Two minor issues worth addressing:

1. Missing disabled button guard

The polyfill handles programmatic button.click() on disabled buttons, but browsers don't fire click events for user-clicks on disabled buttons. Native invoker commands respect the disabled attribute in both cases. Add a guard to stay consistent:

// dom/invoker-polyfill.ts, after finding the button
if (!button || button.disabled) return;

2. e.target null safety

(e.target as Element).closest(...) will throw if e.target is null. It's an unlikely edge case for click events, but easy to guard:

const button = (e.target as Element | null)?.closest(
  "button[commandfor]"
) as HTMLButtonElement | null;

Everything else looks good — the closest()-based delegation correctly handles child element clicks (tested), the feature detection via commandForElement is the right check, and the installed guard prevents double-registration. The scope being limited to dialog commands is appropriate and clearly documented.

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 a small client-side polyfill so the documented Tier 1 dialog pattern using the HTML Invoker Commands API (command / commandfor) works in browsers without native support, while remaining a no-op when native support is detected.

Changes:

  • Introduces setupInvokerPolyfill() to handle show-modal / close invoker commands for <dialog> targets via document-level click delegation.
  • Wires the polyfill into LiveTemplateClient.connect() so it’s enabled by default for client users.
  • Adds a dedicated Jest test suite covering the polyfill’s behavior and guardrails.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
dom/invoker-polyfill.ts New polyfill implementation with feature detection + install/teardown hooks.
livetemplate-client.ts Calls setupInvokerPolyfill() during client connection initialization.
tests/invoker-polyfill.test.ts Adds unit tests validating open/close behavior, skipping, and edge cases.

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

Comment thread tests/invoker-polyfill.test.ts Outdated
Comment on lines +169 to +193
// Simulate native support by adding commandForElement to prototype
Object.defineProperty(HTMLButtonElement.prototype, "commandForElement", {
value: null,
writable: true,
configurable: true,
});

const dialog = document.createElement("dialog");
dialog.id = "test-dialog";
const { showModal } = mockDialogMethods(dialog);
document.body.appendChild(dialog);

const button = document.createElement("button");
button.setAttribute("command", "show-modal");
button.setAttribute("commandfor", "test-dialog");
document.body.appendChild(button);

setupInvokerPolyfill();
button.click();

// Polyfill should not have installed its listener
expect(showModal).not.toHaveBeenCalled();

// Clean up the mock property
delete (HTMLButtonElement.prototype as any).commandForElement;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

This test mutates HTMLButtonElement.prototype but the cleanup (delete ...commandForElement) is done inline at the end of the test. If an assertion throws before that line runs, the prototype change can leak into later tests and cause confusing failures. Consider capturing the original property descriptor and restoring it in an afterEach (or using a try/finally in the test) so cleanup is guaranteed.

Suggested change
// Simulate native support by adding commandForElement to prototype
Object.defineProperty(HTMLButtonElement.prototype, "commandForElement", {
value: null,
writable: true,
configurable: true,
});
const dialog = document.createElement("dialog");
dialog.id = "test-dialog";
const { showModal } = mockDialogMethods(dialog);
document.body.appendChild(dialog);
const button = document.createElement("button");
button.setAttribute("command", "show-modal");
button.setAttribute("commandfor", "test-dialog");
document.body.appendChild(button);
setupInvokerPolyfill();
button.click();
// Polyfill should not have installed its listener
expect(showModal).not.toHaveBeenCalled();
// Clean up the mock property
delete (HTMLButtonElement.prototype as any).commandForElement;
const originalCommandForElementDescriptor = Object.getOwnPropertyDescriptor(
HTMLButtonElement.prototype,
"commandForElement",
);
try {
// Simulate native support by adding commandForElement to prototype
Object.defineProperty(HTMLButtonElement.prototype, "commandForElement", {
value: null,
writable: true,
configurable: true,
});
const dialog = document.createElement("dialog");
dialog.id = "test-dialog";
const { showModal } = mockDialogMethods(dialog);
document.body.appendChild(dialog);
const button = document.createElement("button");
button.setAttribute("command", "show-modal");
button.setAttribute("commandfor", "test-dialog");
document.body.appendChild(button);
setupInvokerPolyfill();
button.click();
// Polyfill should not have installed its listener
expect(showModal).not.toHaveBeenCalled();
} finally {
if (originalCommandForElementDescriptor) {
Object.defineProperty(
HTMLButtonElement.prototype,
"commandForElement",
originalCommandForElementDescriptor,
);
} else {
delete (HTMLButtonElement.prototype as any).commandForElement;
}
}

Copilot uses AI. Check for mistakes.
adnaan added a commit to livetemplate/examples that referenced this pull request Apr 11, 2026
…andfor

Demonstrates Tier 1 dialog routing using the Invoker Commands API
(polyfilled by the client for cross-browser support):

- command="show-modal" commandfor="add-dialog" opens <dialog> via .showModal()
- command="close" commandfor="add-dialog" closes via .close()
- <form method="dialog" name="add"> closes dialog AND routes to Add()
- No lvt-* attributes needed — pure standard HTML

Includes E2E chromedp tests covering: dialog open/close, item add via
dialog form, delete, full page state assertions, and UI standards.

Depends on client polyfill (livetemplate/client#57).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The function name setupInvokerPolyfill already communicates its purpose.

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

claude bot commented Apr 11, 2026

Good implementation overall — clean feature detection, proper singleton guard, comprehensive tests.

One bug to fix: In handleClick, e.target is cast directly to Element without a null/type check:

const button = (e.target as Element).closest("button[commandfor]") as HTMLButtonElement | null;

If e.target is null (e.g. the element was removed from the DOM mid-dispatch) or is not an Element (e.g. a text node), .closest() will throw a TypeError. Add a guard:

const el = e.target;
if (!el || !(el instanceof Element)) return;
const button = el.closest("button[commandfor]") as HTMLButtonElement | null;

Minor note: The polyfill is only installed once globally (module-level installed flag), which is correct — but if LiveTemplateClient is ever instantiated before DOMContentLoaded, the document.addEventListener call in handleClick references document.getElementById at click time, so that's fine. No issue there.

Otherwise LGTM. The scope limitation to <dialog> elements only is appropriate and well-documented.

adnaan added a commit to livetemplate/lvt that referenced this pull request Apr 11, 2026
…andfor

Generated resource templates now use native <dialog> with
command="show-modal"/command="close" (Tier 1) instead of
<div hidden> with lvt-el:toggleAttr+data-lvt-target (Tier 2).

Benefits:
- Native backdrop, focus trapping, Escape key via .showModal()
- Eliminates inline positioning/backdrop styles (~50 chars per modal)
- form method="dialog" closes dialog AND routes action to server
- Tier 1 (standard HTML) — no lvt-* attributes needed

Depends on client polyfill (livetemplate/client#57) for cross-browser
support (Chrome 135+ natively, polyfilled for Firefox/Safari).

Edit modal remains server-managed (conditional render) — unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Guard against e.target being null or non-Element (e.g. text node,
  element removed mid-dispatch) before calling .closest()
- Skip disabled buttons to match native Invoker Commands behavior

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

claude bot commented Apr 11, 2026

Good overall — the polyfill is clean and the feature detection approach is correct. Two issues worth fixing before merge:

1. Test cleanup leak (medium)

In the skips polyfill when native support exists test, the commandForElement property is deleted at the bottom of the test body. If the assertion before it throws, the cleanup never runs, leaving a mutated HTMLButtonElement.prototype that could silently break subsequent tests. Wrap in try/finally or move the deletion into the suite's afterEach:

afterEach(() => {
  teardownInvokerPolyfill();
  delete (HTMLButtonElement.prototype as any).commandForElement;
  document.body.innerHTML = '';
});

2. Module singleton + teardown risk (medium)

The module-level installed flag means setupInvokerPolyfill() is a no-op after the first call — fine for normal use. But teardownInvokerPolyfill() removes the global listener unconditionally. If LiveTemplateClient ever wires teardownInvokerPolyfill into a destroy() path (e.g. for hot-reload or multi-instance scenarios), a single client teardown would kill the polyfill for all remaining instances. Either add a ref-count, or document that teardown is only for tests and should not be called from production client cleanup.

Minor note (no action needed): Unlike setupFxLifecycleListeners(this.wrapperElement), the polyfill attaches to document globally — intentional for a polyfill, but worth a brief comment in livetemplate-client.ts near the call site so the asymmetry is obvious.

…rnal

- Move prototype cleanup into afterEach so it runs even if assertions
  throw, preventing leaked state from affecting subsequent tests.
- Mark teardownInvokerPolyfill as @internal test-only to prevent
  misuse in production client cleanup paths.

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

claude bot commented Apr 11, 2026

Review: polyfill command/commandfor for cross-browser dialog support

Overall this is well-structured with good test coverage. A few things worth addressing:


Bug risk: bubble-phase listener can be silently bypassed (dom/invoker-polyfill.ts:37)

document.addEventListener(click, handleClick);

The polyfill registers on the bubble phase. If any intermediate element calls e.stopPropagation() before the event reaches document, the polyfill silently does nothing. Native invoker commands fire in the capture phase. Consider:

document.addEventListener(click, handleClick, { capture: true });
// and in teardown:
document.removeEventListener(click, handleClick, { capture: true });

Docblock overpromises scope (dom/invoker-polyfill.ts:1-8)

The file comment says it polyfills "the HTML Invoker Commands API (command/commandfor)" but the implementation only handles show-modal and close on <dialog> elements. Popover commands (show-popover, hide-popover, toggle-popover) are silently ignored. This is probably intentional given the PR scope, but the docblock should say so explicitly to avoid confusion when someone tries to use it for popovers later.


Missing idempotency test

There's no test for calling setupInvokerPolyfill() twice without an intervening teardownInvokerPolyfill(). The installed flag should prevent double-registration, but this isn't exercised. Worth adding given that setupReactiveAttributeListeners() (the call just above it in livetemplate-client.ts) could set a precedent for calling setup functions multiple times.


No security concerns. The event target + ID lookup + dialog-method calls path is safe.

@adnaan adnaan merged commit 565176c into main Apr 11, 2026
6 checks passed
@adnaan adnaan deleted the invoker-polyfill branch April 11, 2026 05:00
adnaan added a commit to livetemplate/examples that referenced this pull request Apr 11, 2026
* feat: add dialog-patterns example with native <dialog> + command/commandfor

Demonstrates Tier 1 dialog routing using the Invoker Commands API
(polyfilled by the client for cross-browser support):

- command="show-modal" commandfor="add-dialog" opens <dialog> via .showModal()
- command="close" commandfor="add-dialog" closes via .close()
- <form method="dialog" name="add"> closes dialog AND routes to Add()
- No lvt-* attributes needed — pure standard HTML

Includes E2E chromedp tests covering: dialog open/close, item add via
dialog form, delete, full page state assertions, and UI standards.

Depends on client polyfill (livetemplate/client#57).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add error-path E2E test for empty title submission

Exercises the Add() error path (empty title returns error), verifying
item count remains unchanged after the failed submission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove method=dialog from add form

Dialog closes on success via form-lifecycle-manager, not on submit.
This ensures validation errors remain visible if the server rejects
the submission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: re-trigger after client v0.8.21 release

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adnaan added a commit to livetemplate/lvt that referenced this pull request Apr 11, 2026
* feat: migrate add modal from div+hidden to native dialog+command/commandfor

Generated resource templates now use native <dialog> with
command="show-modal"/command="close" (Tier 1) instead of
<div hidden> with lvt-el:toggleAttr+data-lvt-target (Tier 2).

Benefits:
- Native backdrop, focus trapping, Escape key via .showModal()
- Eliminates inline positioning/backdrop styles (~50 chars per modal)
- form method="dialog" closes dialog AND routes action to server
- Tier 1 (standard HTML) — no lvt-* attributes needed

Depends on client polyfill (livetemplate/client#57) for cross-browser
support (Chrome 135+ natively, polyfilled for Firefox/Safari).

Edit modal remains server-managed (conditional render) — unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update golden file for native dialog template migration

Regenerated with UPDATE_GOLDEN=1 to reflect the div+hidden → dialog
+ command/commandfor changes in generator templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address bot review comments — remove method=dialog, add backdrop

- Remove method="dialog" from add forms: prevents dialog from closing
  before server validation errors can be displayed. The client's
  form-lifecycle-manager already closes parent <dialog> on success.
- Add dialog::backdrop styling (background: rgba(0,0,0,0.5)) to match
  the old overlay behavior.
- Make parity test pattern more specific (command="show-modal" +
  commandfor) to avoid false positives from close buttons.
- Update golden file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: re-trigger after client v0.8.21 release (polyfill now in CDN @latest)

* fix: update E2E test selectors for native dialog pattern

- Replace [data-lvt-target="#add-modal"] with [command="show-modal"][commandfor="add-modal"]
- Replace hidden attribute checks with dialog.open checks
- Replace removeAttribute('hidden') with showModal() calls
- Update diagnostic queries from [role="dialog"] to dialog element

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update remaining E2E test selectors for native dialog

Missed 5 more test files with old [data-lvt-target="#add-modal"]
selectors: embedded_browser_test, pagemode_test, resource_generation_test,
tutorial_test, and complete_workflow_test (add dialog close wait before
edit step to prevent focus issue).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix remaining old selectors and edit form focus issue

- Update tutorial_test.go: replace role="dialog" hidden check with
  dialog.open check
- Fix complete_workflow_test.go edit step: use JavaScript to set
  input value instead of chromedp.Clear which fails with "not focusable"
  after a dialog was in the top layer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use JS for edit form submit (focus/click reliability with dialog)

Submit the edit form via JavaScript button.click() to avoid
CDP focus issues when a dialog was recently in the top layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (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