diff --git a/.changeset/famous-elements-exist.md b/.changeset/famous-elements-exist.md new file mode 100644 index 00000000..fb3098ca --- /dev/null +++ b/.changeset/famous-elements-exist.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Install standard HTML element constructor globals with tag-aware `instanceof` checks while preserving the polyfill's flat element model. diff --git a/packages/polyfill/source/HTMLElement.ts b/packages/polyfill/source/HTMLElement.ts index 33753d89..2203aa0d 100644 --- a/packages/polyfill/source/HTMLElement.ts +++ b/packages/polyfill/source/HTMLElement.ts @@ -1,3 +1,18 @@ +import {NamespaceURI} from './constants.ts'; import {Element} from './Element.ts'; -export class HTMLElement extends Element {} +export class HTMLElement extends Element { + static [Symbol.hasInstance](value: unknown) { + // Custom element subclasses still use their real prototype chain. The base + // HTMLElement check also recognizes the polyfill's deliberately-flat HTML + // elements without requiring createElement() to construct a second tree of + // tag-specific classes. + if (this !== HTMLElement) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } + + return ( + value instanceof Element && value.namespaceURI === NamespaceURI.XHTML + ); + } +} diff --git a/packages/polyfill/source/HTMLElements.ts b/packages/polyfill/source/HTMLElements.ts new file mode 100644 index 00000000..19dda79d --- /dev/null +++ b/packages/polyfill/source/HTMLElements.ts @@ -0,0 +1,186 @@ +import {NamespaceURI} from './constants.ts'; +import {Element} from './Element.ts'; + +type ElementConstructor = typeof Element; + +const HTML_ELEMENT_LOCAL_NAMES = { + HTMLAnchorElement: ['a'], + HTMLAreaElement: ['area'], + HTMLAudioElement: ['audio'], + HTMLBaseElement: ['base'], + HTMLBodyElement: ['body'], + HTMLBRElement: ['br'], + HTMLButtonElement: ['button'], + HTMLCanvasElement: ['canvas'], + HTMLDListElement: ['dl'], + HTMLDataElement: ['data'], + HTMLDataListElement: ['datalist'], + HTMLDetailsElement: ['details'], + HTMLDialogElement: ['dialog'], + HTMLDirectoryElement: ['dir'], + HTMLDivElement: ['div'], + HTMLEmbedElement: ['embed'], + HTMLFieldSetElement: ['fieldset'], + HTMLFontElement: ['font'], + HTMLFormElement: ['form'], + HTMLFrameElement: ['frame'], + HTMLFrameSetElement: ['frameset'], + HTMLHeadElement: ['head'], + HTMLHeadingElement: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + HTMLHRElement: ['hr'], + HTMLHtmlElement: ['html'], + HTMLIFrameElement: ['iframe'], + HTMLImageElement: ['img'], + HTMLInputElement: ['input'], + HTMLLabelElement: ['label'], + HTMLLegendElement: ['legend'], + HTMLLIElement: ['li'], + HTMLLinkElement: ['link'], + HTMLMapElement: ['map'], + HTMLMarqueeElement: ['marquee'], + HTMLMediaElement: ['audio', 'video'], + HTMLMenuElement: ['menu'], + HTMLMetaElement: ['meta'], + HTMLMeterElement: ['meter'], + HTMLModElement: ['del', 'ins'], + HTMLOListElement: ['ol'], + HTMLObjectElement: ['object'], + HTMLOptGroupElement: ['optgroup'], + HTMLOptionElement: ['option'], + HTMLOutputElement: ['output'], + HTMLParagraphElement: ['p'], + HTMLParamElement: ['param'], + HTMLPictureElement: ['picture'], + HTMLPreElement: ['pre', 'listing', 'xmp'], + HTMLProgressElement: ['progress'], + HTMLQuoteElement: ['blockquote', 'q'], + HTMLScriptElement: ['script'], + HTMLSelectElement: ['select'], + HTMLSlotElement: ['slot'], + HTMLSourceElement: ['source'], + HTMLSpanElement: ['span'], + HTMLStyleElement: ['style'], + HTMLTableCaptionElement: ['caption'], + HTMLTableCellElement: ['td', 'th'], + HTMLTableColElement: ['col', 'colgroup'], + HTMLTableElement: ['table'], + HTMLTableRowElement: ['tr'], + HTMLTableSectionElement: ['tbody', 'tfoot', 'thead'], + HTMLTextAreaElement: ['textarea'], + HTMLTimeElement: ['time'], + HTMLTitleElement: ['title'], + HTMLTrackElement: ['track'], + HTMLUListElement: ['ul'], + HTMLVideoElement: ['video'], +} as const; + +const GENERIC_HTML_LOCAL_NAMES = [ + 'abbr', + 'acronym', + 'address', + 'article', + 'aside', + 'b', + 'basefont', + 'bdi', + 'bdo', + 'big', + 'center', + 'cite', + 'code', + 'dd', + 'dfn', + 'dt', + 'em', + 'figcaption', + 'figure', + 'footer', + 'header', + 'hgroup', + 'i', + 'kbd', + 'main', + 'mark', + 'menuitem', + 'nav', + 'nobr', + 'noembed', + 'noframes', + 'noscript', + 'plaintext', + 'rb', + 'rp', + 'rt', + 'rtc', + 'ruby', + 's', + 'samp', + 'search', + 'section', + 'small', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'template', + 'tt', + 'u', + 'var', + 'wbr', +] as const; + +const KNOWN_HTML_LOCAL_NAMES = new Set([ + ...GENERIC_HTML_LOCAL_NAMES, + ...Object.values(HTML_ELEMENT_LOCAL_NAMES).flat(), +]); + +function isHTMLElement(value: unknown): value is Element { + return value instanceof Element && value.namespaceURI === NamespaceURI.XHTML; +} + +function createElementConstructor( + name: string, + matches: (element: Element) => boolean, +) { + const Constructor = function () { + throw new TypeError('Illegal constructor'); + } as unknown as ElementConstructor; + + Object.defineProperties(Constructor, { + name: {value: name}, + prototype: {value: Element.prototype}, + [Symbol.hasInstance]: { + value(value: unknown) { + return isHTMLElement(value) && matches(value); + }, + }, + }); + + return Constructor; +} + +const TAG_SPECIFIC_HTML_ELEMENT_GLOBALS = Object.fromEntries( + Object.entries(HTML_ELEMENT_LOCAL_NAMES).map(([name, localNames]) => { + const names = new Set(localNames); + return [ + name, + createElementConstructor(name, (element) => + names.has(element.localName.toLowerCase()), + ), + ]; + }), +) as { + [Name in keyof typeof HTML_ELEMENT_LOCAL_NAMES]: ElementConstructor; +}; + +export const HTML_ELEMENT_GLOBALS = { + ...TAG_SPECIFIC_HTML_ELEMENT_GLOBALS, + HTMLUnknownElement: createElementConstructor( + 'HTMLUnknownElement', + (element) => { + const localName = element.localName.toLowerCase(); + return !localName.includes('-') && !KNOWN_HTML_LOCAL_NAMES.has(localName); + }, + ), +}; diff --git a/packages/polyfill/source/Window.ts b/packages/polyfill/source/Window.ts index f61a1e94..2e9c1fc3 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 {HTML_ELEMENT_GLOBALS} from './HTMLElements.ts'; import {HOOKS} from './constants.ts'; import type {Hooks} from './hooks.ts'; @@ -33,6 +34,10 @@ type OnErrorHandler = ) => void) | null; +type HTMLElementGlobals = typeof HTML_ELEMENT_GLOBALS; + +export interface Window extends HTMLElementGlobals {} + export class Window extends EventTarget { [HOOKS]: Partial = {}; name = ''; @@ -66,6 +71,11 @@ export class Window extends EventTarget { HTMLTemplateElement = HTMLTemplateElement; MutationObserver = MutationObserver; + constructor() { + super(); + Object.assign(this, HTML_ELEMENT_GLOBALS); + } + #currentOnErrorHandler: ((event: any) => void) | null = null; #currentOriginalOnErrorHandler: OnErrorHandler = null; #currentOnUnhandledRejectionHandler: WindowEventHandlers['onunhandledrejection'] = diff --git a/packages/polyfill/source/tests/HTMLElements.test.ts b/packages/polyfill/source/tests/HTMLElements.test.ts new file mode 100644 index 00000000..6df48ca1 --- /dev/null +++ b/packages/polyfill/source/tests/HTMLElements.test.ts @@ -0,0 +1,197 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {Window} from '../Window.ts'; + +describe('HTML element constructors', () => { + let window: Window; + + beforeEach(() => { + window = new Window(); + }); + + it.each([ + ['a', 'HTMLAnchorElement'], + ['button', 'HTMLButtonElement'], + ['form', 'HTMLFormElement'], + ['img', 'HTMLImageElement'], + ['input', 'HTMLInputElement'], + ['select', 'HTMLSelectElement'], + ['table', 'HTMLTableElement'], + ['td', 'HTMLTableCellElement'], + ['textarea', 'HTMLTextAreaElement'], + ])('creates <%s> with %s identity', (tagName, constructorName) => { + const Constructor = (window as any)[constructorName]; + const element = window.document.createElement(tagName); + + expect(Constructor).toBeTypeOf('function'); + expect(element).toBeInstanceOf(Constructor); + expect(element).toBeInstanceOf(window.HTMLElement); + expect(element).toBeInstanceOf(window.Element); + }); + + it('keeps ordinary elements on the shared Element prototype', () => { + const input = window.document.createElement('input'); + + expect(input.constructor).toBe(window.Element); + expect(Object.getPrototypeOf(input)).toBe(window.Element.prototype); + expect(input).toBeInstanceOf(window.HTMLInputElement); + expect(input).toBeInstanceOf(window.HTMLElement); + }); + + it('keeps distinct element constructor identities', () => { + const input = window.document.createElement('input'); + + expect(input).toBeInstanceOf(window.HTMLInputElement); + expect(input).not.toBeInstanceOf(window.HTMLFormElement); + }); + + it('uses the shared media superclass for audio and video', () => { + expect(window.document.createElement('audio')).toBeInstanceOf( + window.HTMLMediaElement, + ); + expect(window.document.createElement('video')).toBeInstanceOf( + window.HTMLMediaElement, + ); + }); + + it('identifies generic, unknown, and unregistered custom elements', () => { + const article = window.document.createElement('article'); + const custom = window.document.createElement('not-an-html-tag'); + const unknown = window.document.createElement('unknown'); + + expect(article.constructor).toBe(window.Element); + expect(article).toBeInstanceOf(window.HTMLElement); + expect(article).not.toBeInstanceOf(window.HTMLUnknownElement); + expect(custom).toBeInstanceOf(window.HTMLElement); + expect(custom).not.toBeInstanceOf(window.HTMLUnknownElement); + expect(unknown).toBeInstanceOf(window.HTMLUnknownElement); + }); + + it('does not identify SVG elements as HTML elements', () => { + const anchor = window.document.createElementNS( + 'http://www.w3.org/2000/svg' as any, + 'a', + ); + + expect(anchor).not.toBeInstanceOf(window.HTMLElement); + expect(anchor).not.toBeInstanceOf(window.HTMLAnchorElement); + }); + + it('preserves constructors for built-in document elements and templates', () => { + expect(window.document.documentElement).toBeInstanceOf( + window.HTMLHtmlElement, + ); + expect(window.document.head).toBeInstanceOf(window.HTMLHeadElement); + expect(window.document.body).toBeInstanceOf(window.HTMLBodyElement); + expect(window.document.createElement('template')).toBeInstanceOf( + window.HTMLTemplateElement, + ); + }); + + it('continues to construct registered custom elements', () => { + class TestElement extends window.HTMLElement {} + + window.customElements.define('test-element', TestElement as any); + + const element = window.document.createElement('test-element'); + + expect(element).toBeInstanceOf(TestElement); + expect(element).toBeInstanceOf(window.HTMLElement); + }); + + it('keeps custom element subclass instanceof checks prototype-based', () => { + class FirstElement extends window.HTMLElement {} + class SecondElement extends window.HTMLElement {} + + window.customElements.define('first-element', FirstElement as any); + const element = window.document.createElement('first-element'); + + expect(element).toBeInstanceOf(FirstElement); + expect(element).not.toBeInstanceOf(SecondElement); + }); + + it('does not construct tag-specific facade classes', () => { + expect(() => new window.HTMLInputElement()).toThrowError( + new TypeError('Illegal constructor'), + ); + }); + + it('installs every standard HTML element constructor as a global', () => { + const constructorNames = [ + 'HTMLAnchorElement', + 'HTMLAreaElement', + 'HTMLAudioElement', + 'HTMLBRElement', + 'HTMLBaseElement', + 'HTMLBodyElement', + 'HTMLButtonElement', + 'HTMLCanvasElement', + 'HTMLDListElement', + 'HTMLDataElement', + 'HTMLDataListElement', + 'HTMLDetailsElement', + 'HTMLDialogElement', + 'HTMLDirectoryElement', + 'HTMLDivElement', + 'HTMLEmbedElement', + 'HTMLFieldSetElement', + 'HTMLFontElement', + 'HTMLFormElement', + 'HTMLFrameElement', + 'HTMLFrameSetElement', + 'HTMLHRElement', + 'HTMLHeadElement', + 'HTMLHeadingElement', + 'HTMLHtmlElement', + 'HTMLIFrameElement', + 'HTMLImageElement', + 'HTMLInputElement', + 'HTMLLIElement', + 'HTMLLabelElement', + 'HTMLLegendElement', + 'HTMLLinkElement', + 'HTMLMapElement', + 'HTMLMarqueeElement', + 'HTMLMediaElement', + 'HTMLMenuElement', + 'HTMLMetaElement', + 'HTMLMeterElement', + 'HTMLModElement', + 'HTMLOListElement', + 'HTMLObjectElement', + 'HTMLOptGroupElement', + 'HTMLOptionElement', + 'HTMLOutputElement', + 'HTMLParagraphElement', + 'HTMLParamElement', + 'HTMLPictureElement', + 'HTMLPreElement', + 'HTMLProgressElement', + 'HTMLQuoteElement', + 'HTMLScriptElement', + 'HTMLSelectElement', + 'HTMLSlotElement', + 'HTMLSourceElement', + 'HTMLSpanElement', + 'HTMLStyleElement', + 'HTMLTableCaptionElement', + 'HTMLTableCellElement', + 'HTMLTableColElement', + 'HTMLTableElement', + 'HTMLTableRowElement', + 'HTMLTableSectionElement', + 'HTMLTemplateElement', + 'HTMLTextAreaElement', + 'HTMLTimeElement', + 'HTMLTitleElement', + 'HTMLTrackElement', + 'HTMLUListElement', + 'HTMLUnknownElement', + 'HTMLVideoElement', + ]; + + for (const name of constructorNames) { + expect((window as any)[name], name).toBeTypeOf('function'); + } + }); +});