feat: polyfill command/commandfor for cross-browser dialog support#57
feat: polyfill command/commandfor for cross-browser dialog support#57
Conversation
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>
|
Good polyfill — clean implementation with solid test coverage. Two minor issues worth addressing: 1. Missing The polyfill handles programmatic // dom/invoker-polyfill.ts, after finding the button
if (!button || button.disabled) return;2.
const button = (e.target as Element | null)?.closest(
"button[commandfor]"
) as HTMLButtonElement | null;Everything else looks good — the |
There was a problem hiding this comment.
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 handleshow-modal/closeinvoker 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.
| // 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; |
There was a problem hiding this comment.
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.
| // 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; | |
| } | |
| } |
…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>
|
Good implementation overall — clean feature detection, proper singleton guard, comprehensive tests. One bug to fix: In const button = (e.target as Element).closest("button[commandfor]") as HTMLButtonElement | null;If 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 Otherwise LGTM. The scope limitation to |
…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>
|
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 afterEach(() => {
teardownInvokerPolyfill();
delete (HTMLButtonElement.prototype as any).commandForElement;
document.body.innerHTML = '';
});2. Module singleton + teardown risk (medium) The module-level Minor note (no action needed): Unlike |
…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>
|
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 ( document.addEventListener(click, handleClick);The polyfill registers on the bubble phase. If any intermediate element calls document.addEventListener(click, handleClick, { capture: true });
// and in teardown:
document.removeEventListener(click, handleClick, { capture: true });Docblock overpromises scope ( The file comment says it polyfills "the HTML Invoker Commands API (command/commandfor)" but the implementation only handles Missing idempotency test There's no test for calling No security concerns. The event target + ID lookup + dialog-method calls path is safe. |
* 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>
* 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>
Summary
command/commandfor) so<button command="show-modal" commandfor="dialog-id">works in Firefox and Safari (not just Chrome 135+).showModal()/.close()on target<dialog>elements, keeping dialog open/close in Tier 1 (standard HTML, nolvt-*attributes needed)commandForElementmakes the polyfill a no-op when browsers add native supportContext
The Dialog Routing docs describe
command/commandforas 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
commandforexclusion inevent-delegation.ts:136already prevents these buttons from triggering server actions — this polyfill completes the picture by handling the client-side dialog behavior.Test plan
dialog-patternsexample (separate PR in examples repo, depends on this release)🤖 Generated with Claude Code