Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-workers-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@remote-dom/polyfill': minor
---

Support constructing native worker `FormData` from Remote DOM forms.
3 changes: 3 additions & 0 deletions packages/polyfill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
39 changes: 39 additions & 0 deletions packages/polyfill/source/FormData.ts
Original file line number Diff line number Diff line change
@@ -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') ?? '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

checked elements submit "on" as a fallback if the value attribute isn't there

Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state:

If the field element has a value attribute specified, then let value be the value of that attribute; otherwise, let value be the string "on".

step 7.1 here https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-form-data-set

}
}

return data;
}

if (NativeFormData) FormData.prototype = NativeFormData.prototype;
2 changes: 2 additions & 0 deletions packages/polyfill/source/Window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
206 changes: 206 additions & 0 deletions packages/polyfill/source/tests/FormData.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
Loading