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..42c6f5bf 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, 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 new file mode 100644 index 00000000..d497f28e --- /dev/null +++ b/packages/polyfill/source/FormData.ts @@ -0,0 +1,39 @@ +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'); + 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(); + if (type === 'button') continue; + if (type === 'image') continue; + if (type === 'reset') continue; + if (type === 'submit') continue; + + if ( + 'checked' in control && + !(control.checked ?? control.hasAttribute('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..9684bc79 --- /dev/null +++ b/packages/polyfill/source/tests/FormData.test.ts @@ -0,0 +1,206 @@ +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('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 = [ + ['input', null, 'unnamed'], + ['input', 'disabled-attribute', 'disabled'], + ['input', 'disabled-property', null], + ['remote-checkbox', 'unchecked-checkbox', null], + ['remote-radio', 'unchecked-radio', null], + ['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; + } else if (name?.startsWith('unchecked-')) { + control.checked = false; + } + form.append(control); + } + + const unchecked = document.createElement('remote-toggle') as any; + unchecked.setAttribute('name', 'currently-unchecked'); + unchecked.setAttribute('checked', ''); + unchecked.checked = false; + form.append(unchecked); + + expect([...new FormData(form)]).toEqual([]); + }); + + it('uses the checked property exposed by custom elements', () => { + const form = document.createElement('form'); + 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 () => { + 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; + } + }); +});