From fd0fed80477da6e8d01b8c3e43827ac3782ab87e Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Wed, 15 Jul 2026 23:51:59 -0400 Subject: [PATCH 1/3] Bridge Remote DOM forms to native FormData Capture the environment native constructor and populate it from the common subset of successful Remote DOM controls. This keeps native storage, file handling, iteration, and multipart Request interoperability intact. --- .changeset/tidy-workers-share.md | 5 + packages/polyfill/README.md | 3 + packages/polyfill/source/FormData.ts | 37 ++++ packages/polyfill/source/Window.ts | 2 + .../polyfill/source/tests/FormData.test.ts | 161 ++++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 .changeset/tidy-workers-share.md create mode 100644 packages/polyfill/source/FormData.ts create mode 100644 packages/polyfill/source/tests/FormData.test.ts diff --git a/.changeset/tidy-workers-share.md b/.changeset/tidy-workers-share.md new file mode 100644 index 00000000..70d4ef83 --- /dev/null +++ b/.changeset/tidy-workers-share.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Support constructing native worker `FormData` from Remote DOM forms. diff --git a/packages/polyfill/README.md b/packages/polyfill/README.md index 7d21dc8a..d27238a5 100644 --- a/packages/polyfill/README.md +++ b/packages/polyfill/README.md @@ -33,6 +33,9 @@ This process will install polyfilled versions of the following globals: - [`customElements`](https://developer.mozilla.org/en-US/docs/Web/API/Window/customElements) - [`location`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location) and [`navigator`](https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator), though these are just set to `globalThis.location` and `globalThis.navigator`. - The [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent), [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node), [`ParentNode`](https://developer.mozilla.org/en-US/docs/Web/API/ParentNode), [`ChildNode`](https://developer.mozilla.org/en-US/docs/Web/API/ChildNode), [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document), [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment), [`CharacterData`](https://developer.mozilla.org/en-US/docs/Web/API/CharacterData), [`Comment`](https://developer.mozilla.org/en-US/docs/Web/API/Comment), [`Text`](https://developer.mozilla.org/en-US/docs/Web/API/Text), [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element), [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement), [`SVGElement`](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement), [`HTMLTemplateElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement), and [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) constructors. +- The [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) constructor. This delegates storage, iteration, file handling, and encoding to the environment’s native worker `FormData`, while allowing a Remote DOM form to be passed to the constructor. + +When constructed with a Remote DOM form, `FormData` collects its named descendants in tree order. It uses current control values, preserves repeated names, and omits disabled controls, unchecked checkboxes and radios, and button-like controls. This is intentionally a small adapter, not a complete implementation of HTML form submission rules. This polyfill lets you hook into many of the operations that happen in the DOM, like creating elements, updating attributes, and adding event listeners. You define these hooks by overwriting any of the properties on the `hooks` export of this library. diff --git a/packages/polyfill/source/FormData.ts b/packages/polyfill/source/FormData.ts new file mode 100644 index 00000000..1b200f3f --- /dev/null +++ b/packages/polyfill/source/FormData.ts @@ -0,0 +1,37 @@ +import type {Element} from './Element.ts'; + +const NativeFormData = globalThis.FormData; + +export function FormData(form?: Element) { + const data = new NativeFormData(); + + if (form) { + for (const control of form.querySelectorAll('[name]')) { + const name = control.getAttribute('name'); + const type = String( + control.type ?? control.getAttribute('type') ?? '', + ).toLowerCase(); + const disabled = control.disabled ?? control.hasAttribute('disabled'); + const checked = control.checked ?? control.hasAttribute('checked'); + + if ( + !name || + disabled || + control.localName.toLowerCase() === 'button' || + type === 'button' || + type === 'image' || + type === 'reset' || + type === 'submit' || + ((type === 'checkbox' || type === 'radio') && !checked) + ) { + continue; + } + + data.append(name, control.value ?? control.getAttribute('value') ?? ''); + } + } + + return data; +} + +if (NativeFormData) FormData.prototype = NativeFormData.prototype; diff --git a/packages/polyfill/source/Window.ts b/packages/polyfill/source/Window.ts index f61a1e94..c13ad692 100644 --- a/packages/polyfill/source/Window.ts +++ b/packages/polyfill/source/Window.ts @@ -20,6 +20,7 @@ import {DocumentFragment} from './DocumentFragment.ts'; import {HTMLTemplateElement} from './HTMLTemplateElement.ts'; import {CustomElementRegistryImplementation} from './CustomElementRegistry.ts'; import {MutationObserver} from './MutationObserver.ts'; +import {FormData} from './FormData.ts'; import {HOOKS} from './constants.ts'; import type {Hooks} from './hooks.ts'; @@ -65,6 +66,7 @@ export class Window extends EventTarget { SVGElement = SVGElement; HTMLTemplateElement = HTMLTemplateElement; MutationObserver = MutationObserver; + FormData = FormData; #currentOnErrorHandler: ((event: any) => void) | null = null; #currentOriginalOnErrorHandler: OnErrorHandler = null; diff --git a/packages/polyfill/source/tests/FormData.test.ts b/packages/polyfill/source/tests/FormData.test.ts new file mode 100644 index 00000000..3f5a7483 --- /dev/null +++ b/packages/polyfill/source/tests/FormData.test.ts @@ -0,0 +1,161 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {Window} from '../index.ts'; + +const NativeFormData = globalThis.FormData; + +describe('FormData', () => { + beforeEach(() => { + Window.setGlobalThis(new Window()); + }); + + it('constructs an empty mutable native FormData', () => { + const data = new FormData(); + + data.append('message', 'hello'); + + expect(data.get('message')).toBe('hello'); + expect(data).toBeInstanceOf(NativeFormData); + expect(data).toBeInstanceOf(FormData); + expect(data.constructor).toBe(NativeFormData); + expect(Object.prototype.toString.call(data)).toBe('[object FormData]'); + }); + + it('is installed by Window.setGlobal()', () => { + const window = new Window(); + + Window.setGlobal(window); + + expect(globalThis.FormData).toBe(window.FormData); + }); + + it('collects named controls in tree order and preserves duplicate names', () => { + const form = document.createElement('form'); + const first = document.createElement('input'); + const group = document.createElement('div'); + const second = document.createElement('input'); + const last = document.createElement('textarea'); + + first.setAttribute('name', 'item'); + first.setAttribute('value', 'first'); + second.setAttribute('name', 'item'); + second.setAttribute('value', 'second'); + last.setAttribute('name', 'note'); + last.setAttribute('value', 'last'); + group.append(second); + form.append(first, group, last); + + expect([...new FormData(form)]).toEqual([ + ['item', 'first'], + ['item', 'second'], + ['note', 'last'], + ]); + }); + + it('prefers current property values to initial attributes', () => { + const form = document.createElement('form'); + const input = document.createElement('input'); + + input.setAttribute('name', 'message'); + input.setAttribute('value', 'initial'); + input.value = 'current'; + form.append(input); + + expect(new FormData(form).get('message')).toBe('current'); + }); + + it('omits unsuccessful controls', () => { + const form = document.createElement('form'); + const controls = [ + ['input', null, 'unnamed'], + ['input', 'disabled-attribute', 'disabled'], + ['input', 'disabled-property', null], + ['input', 'unchecked-checkbox', 'checkbox'], + ['input', 'unchecked-radio', 'radio'], + ['button', 'button-element', null], + ['BUTTON', 'uppercase-button-element', null], + ['input', 'button-input', 'button'], + ['input', 'image-input', 'image'], + ['input', 'reset-input', 'reset'], + ['input', 'submit-input', 'submit'], + ] as const; + + for (const [tag, name, type] of controls) { + const control = document.createElement(tag) as any; + if (name) control.setAttribute('name', name); + control.setAttribute('value', name ?? 'unnamed'); + if (type) control.setAttribute('type', type); + if (name === 'disabled-attribute') { + control.setAttribute('disabled', ''); + } else if (name === 'disabled-property') { + control.disabled = true; + } + form.append(control); + } + + const unchecked = document.createElement('input'); + unchecked.setAttribute('name', 'currently-unchecked'); + unchecked.setAttribute('type', 'checkbox'); + unchecked.setAttribute('checked', ''); + unchecked.checked = false; + form.append(unchecked); + + expect([...new FormData(form)]).toEqual([]); + }); + + it('includes checked checkboxes and radios', () => { + const form = document.createElement('form'); + const checkbox = document.createElement('input'); + const radio = document.createElement('input'); + + checkbox.setAttribute('name', 'choice'); + checkbox.setAttribute('type', 'checkbox'); + checkbox.setAttribute('value', 'one'); + checkbox.checked = true; + radio.setAttribute('name', 'choice'); + radio.type = 'radio'; + radio.value = 'two'; + radio.setAttribute('checked', ''); + form.append(checkbox, radio); + + expect(new FormData(form).getAll('choice')).toEqual(['one', 'two']); + }); + + it('passes Blob and File values to the native FormData', async () => { + const form = document.createElement('form'); + const blobInput = document.createElement('input'); + const fileInput = document.createElement('input'); + const file = new File(['file contents'], 'example.txt'); + + blobInput.setAttribute('name', 'blob'); + blobInput.value = new Blob(['blob contents'], { + type: 'text/plain', + }) as any; + fileInput.setAttribute('name', 'file'); + fileInput.value = file as any; + form.append(blobInput, fileInput); + + const data = new FormData(form); + const blob = data.get('blob'); + + expect(blob).toBeInstanceOf(Blob); + expect((blob as Blob).type).toBe('text/plain'); + expect(await (blob as Blob).text()).toBe('blob contents'); + expect(data.get('file')).toBe(file); + }); + + it('can be imported when native FormData is unavailable', async () => { + const InstalledFormData = globalThis.FormData; + + try { + delete (globalThis as any).FormData; + vi.resetModules(); + + await expect(import('../FormData.ts')).resolves.toHaveProperty( + 'FormData', + ); + } finally { + globalThis.FormData = InstalledFormData; + } + }); +}); From 1c95c637cd4bf941fecef0ace7af8150cd37f276 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Thu, 16 Jul 2026 00:34:34 -0400 Subject: [PATCH 2/3] Detect checked controls by property presence Avoid inferring checkable behavior from input type names so Remote DOM custom elements participate correctly. Cover custom checked properties and document the property-based behavior. --- packages/polyfill/README.md | 2 +- packages/polyfill/source/FormData.ts | 4 +- .../polyfill/source/tests/FormData.test.ts | 46 +++++++++++-------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/polyfill/README.md b/packages/polyfill/README.md index d27238a5..42c6f5bf 100644 --- a/packages/polyfill/README.md +++ b/packages/polyfill/README.md @@ -35,7 +35,7 @@ This process will install polyfilled versions of the following globals: - The [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent), [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node), [`ParentNode`](https://developer.mozilla.org/en-US/docs/Web/API/ParentNode), [`ChildNode`](https://developer.mozilla.org/en-US/docs/Web/API/ChildNode), [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document), [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment), [`CharacterData`](https://developer.mozilla.org/en-US/docs/Web/API/CharacterData), [`Comment`](https://developer.mozilla.org/en-US/docs/Web/API/Comment), [`Text`](https://developer.mozilla.org/en-US/docs/Web/API/Text), [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element), [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement), [`SVGElement`](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement), [`HTMLTemplateElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement), and [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) constructors. - The [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) constructor. This delegates storage, iteration, file handling, and encoding to the environment’s native worker `FormData`, while allowing a Remote DOM form to be passed to the constructor. -When constructed with a Remote DOM form, `FormData` collects its named descendants in tree order. It uses current control values, preserves repeated names, and omits disabled controls, unchecked checkboxes and radios, and button-like controls. This is intentionally a small adapter, not a complete implementation of HTML form submission rules. +When constructed with a Remote DOM form, `FormData` collects its named descendants in tree order. It uses current control values, preserves repeated names, and omits disabled controls, controls that expose an unchecked state, and button-like controls. This is intentionally a small adapter, not a complete implementation of HTML form submission rules. This polyfill lets you hook into many of the operations that happen in the DOM, like creating elements, updating attributes, and adding event listeners. You define these hooks by overwriting any of the properties on the `hooks` export of this library. diff --git a/packages/polyfill/source/FormData.ts b/packages/polyfill/source/FormData.ts index 1b200f3f..3a0883ef 100644 --- a/packages/polyfill/source/FormData.ts +++ b/packages/polyfill/source/FormData.ts @@ -12,7 +12,6 @@ export function FormData(form?: Element) { control.type ?? control.getAttribute('type') ?? '', ).toLowerCase(); const disabled = control.disabled ?? control.hasAttribute('disabled'); - const checked = control.checked ?? control.hasAttribute('checked'); if ( !name || @@ -22,7 +21,8 @@ export function FormData(form?: Element) { type === 'image' || type === 'reset' || type === 'submit' || - ((type === 'checkbox' || type === 'radio') && !checked) + ('checked' in control && + !(control.checked ?? control.hasAttribute('checked'))) ) { continue; } diff --git a/packages/polyfill/source/tests/FormData.test.ts b/packages/polyfill/source/tests/FormData.test.ts index 3f5a7483..09cbb709 100644 --- a/packages/polyfill/source/tests/FormData.test.ts +++ b/packages/polyfill/source/tests/FormData.test.ts @@ -70,8 +70,8 @@ describe('FormData', () => { ['input', null, 'unnamed'], ['input', 'disabled-attribute', 'disabled'], ['input', 'disabled-property', null], - ['input', 'unchecked-checkbox', 'checkbox'], - ['input', 'unchecked-radio', 'radio'], + ['remote-checkbox', 'unchecked-checkbox', null], + ['remote-radio', 'unchecked-radio', null], ['button', 'button-element', null], ['BUTTON', 'uppercase-button-element', null], ['input', 'button-input', 'button'], @@ -89,13 +89,14 @@ describe('FormData', () => { control.setAttribute('disabled', ''); } else if (name === 'disabled-property') { control.disabled = true; + } else if (name?.startsWith('unchecked-')) { + control.checked = false; } form.append(control); } - const unchecked = document.createElement('input'); + const unchecked = document.createElement('remote-toggle') as any; unchecked.setAttribute('name', 'currently-unchecked'); - unchecked.setAttribute('type', 'checkbox'); unchecked.setAttribute('checked', ''); unchecked.checked = false; form.append(unchecked); @@ -103,22 +104,29 @@ describe('FormData', () => { expect([...new FormData(form)]).toEqual([]); }); - it('includes checked checkboxes and radios', () => { + it('uses the checked property exposed by custom elements', () => { const form = document.createElement('form'); - const checkbox = document.createElement('input'); - const radio = document.createElement('input'); - - checkbox.setAttribute('name', 'choice'); - checkbox.setAttribute('type', 'checkbox'); - checkbox.setAttribute('value', 'one'); - checkbox.checked = true; - radio.setAttribute('name', 'choice'); - radio.type = 'radio'; - radio.value = 'two'; - radio.setAttribute('checked', ''); - form.append(checkbox, radio); - - expect(new FormData(form).getAll('choice')).toEqual(['one', 'two']); + const checked = document.createElement('remote-checkbox') as any; + const initiallyChecked = document.createElement('remote-radio') as any; + const unmodeled = document.createElement('input'); + + checked.setAttribute('name', 'choice'); + checked.setAttribute('value', 'one'); + checked.checked = true; + initiallyChecked.setAttribute('name', 'choice'); + initiallyChecked.setAttribute('value', 'two'); + initiallyChecked.setAttribute('checked', ''); + initiallyChecked.checked = undefined; + unmodeled.setAttribute('name', 'choice'); + unmodeled.setAttribute('type', 'checkbox'); + unmodeled.setAttribute('value', 'three'); + form.append(checked, initiallyChecked, unmodeled); + + expect(new FormData(form).getAll('choice')).toEqual([ + 'one', + 'two', + 'three', + ]); }); it('passes Blob and File values to the native FormData', async () => { From 802feae38d88fe56f97785a3774d7a56f12fbbf2 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Thu, 16 Jul 2026 00:36:42 -0400 Subject: [PATCH 3/3] Short-circuit omitted FormData controls Use explicit early continues so omitted controls do not trigger unrelated property reads. Keep each omission rule independent and let downstream minification combine conditions when useful. --- packages/polyfill/source/FormData.ts | 22 ++++++----- .../polyfill/source/tests/FormData.test.ts | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/polyfill/source/FormData.ts b/packages/polyfill/source/FormData.ts index 3a0883ef..d497f28e 100644 --- a/packages/polyfill/source/FormData.ts +++ b/packages/polyfill/source/FormData.ts @@ -8,21 +8,23 @@ export function FormData(form?: Element) { if (form) { for (const control of form.querySelectorAll('[name]')) { const name = control.getAttribute('name'); + if (!name) continue; + + const disabled = control.disabled ?? control.hasAttribute('disabled'); + if (disabled) continue; + if (control.localName.toLowerCase() === 'button') continue; + const type = String( control.type ?? control.getAttribute('type') ?? '', ).toLowerCase(); - const disabled = control.disabled ?? control.hasAttribute('disabled'); + if (type === 'button') continue; + if (type === 'image') continue; + if (type === 'reset') continue; + if (type === 'submit') continue; if ( - !name || - disabled || - control.localName.toLowerCase() === 'button' || - type === 'button' || - type === 'image' || - type === 'reset' || - type === 'submit' || - ('checked' in control && - !(control.checked ?? control.hasAttribute('checked'))) + 'checked' in control && + !(control.checked ?? control.hasAttribute('checked')) ) { continue; } diff --git a/packages/polyfill/source/tests/FormData.test.ts b/packages/polyfill/source/tests/FormData.test.ts index 09cbb709..9684bc79 100644 --- a/packages/polyfill/source/tests/FormData.test.ts +++ b/packages/polyfill/source/tests/FormData.test.ts @@ -64,6 +64,43 @@ describe('FormData', () => { expect(new FormData(form).get('message')).toBe('current'); }); + it('stops reading control state after an early omission', () => { + const form = document.createElement('form'); + const unnamed = document.createElement('remote-input'); + const disabled = document.createElement('remote-input'); + + unnamed.setAttribute('name', ''); + Object.defineProperties(unnamed, { + disabled: { + get() { + throw new Error('disabled should not be read'); + }, + }, + type: { + get() { + throw new Error('type should not be read'); + }, + }, + }); + disabled.setAttribute('name', 'disabled'); + Object.defineProperties(disabled, { + disabled: {value: true}, + type: { + get() { + throw new Error('type should not be read'); + }, + }, + checked: { + get() { + throw new Error('checked should not be read'); + }, + }, + }); + form.append(unnamed, disabled); + + expect(() => new FormData(form)).not.toThrow(); + }); + it('omits unsuccessful controls', () => { const form = document.createElement('form'); const controls = [