From 516fcfa4c4a04e8ff5a0e7c6b7da0d38d2dd1f57 Mon Sep 17 00:00:00 2001 From: Olavo Santos Date: Wed, 2 Sep 2026 09:22:22 -0500 Subject: [PATCH] Respect node namespace context in isDefaultNamespace --- .changeset/respect-default-namespace.md | 5 + packages/polyfill/source/Node.ts | 56 ++++++++- .../source/tests/is-default-namespace.test.ts | 111 ++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 .changeset/respect-default-namespace.md create mode 100644 packages/polyfill/source/tests/is-default-namespace.test.ts diff --git a/.changeset/respect-default-namespace.md b/.changeset/respect-default-namespace.md new file mode 100644 index 00000000..d922d3f2 --- /dev/null +++ b/.changeset/respect-default-namespace.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Resolve `Node.isDefaultNamespace()` with DOM locate-a-namespace semantics. diff --git a/packages/polyfill/source/Node.ts b/packages/polyfill/source/Node.ts index 1f3e6f52..d82364b5 100644 --- a/packages/polyfill/source/Node.ts +++ b/packages/polyfill/source/Node.ts @@ -6,16 +6,24 @@ import { CHILD, PREV, NEXT, - HTML_NAMESPACE, + NODE_TYPE_ATTRIBUTE, + NODE_TYPE_DOCUMENT, + NODE_TYPE_DOCUMENT_FRAGMENT, + NODE_TYPE_DOCUMENT_TYPE, + NODE_TYPE_ELEMENT, NODE_TYPE_NODE, + XMLNS_NAMESPACE, type NodeType, HOOKS, HOOKS_DISPATCH, IS_CONNECTED, } from './constants.ts'; +import type {Attr} from './Attr.ts'; import type {Document} from './Document.ts'; +import type {Element} from './Element.ts'; import type {ParentNode} from './ParentNode.ts'; import {EventTarget} from './EventTarget.ts'; +import {normalizeNamespace} from './names.ts'; import { isCharacterData, isParentNode, @@ -56,8 +64,9 @@ export class Node extends EventTarget { return this[IS_CONNECTED]; } - isDefaultNamespace(namespace: string): namespace is typeof HTML_NAMESPACE { - return namespace === HTML_NAMESPACE; + isDefaultNamespace(namespace: string | null): boolean { + const normalizedNamespace = normalizeNamespace(namespace); + return locateNamespace(this) === normalizedNamespace; } get parentNode() { @@ -169,3 +178,44 @@ export class Node extends EventTarget { } } } + +function locateNamespace(node: Node | null): string | null { + let current = node; + + while (current) { + switch (current.nodeType) { + case NODE_TYPE_ELEMENT: { + const element = current as Element; + + if (element.prefix == null && element.namespaceURI != null) { + return element.namespaceURI; + } + + const namespace = element.attributes.getNamedItemNS( + XMLNS_NAMESPACE, + 'xmlns', + ); + + if (namespace != null && namespace.prefix == null) { + return normalizeNamespace(namespace.value); + } + + current = element.parentElement; + break; + } + case NODE_TYPE_DOCUMENT: + current = (current as Document).documentElement; + break; + case NODE_TYPE_DOCUMENT_TYPE: + case NODE_TYPE_DOCUMENT_FRAGMENT: + return null; + case NODE_TYPE_ATTRIBUTE: + current = (current as Attr).ownerElement; + break; + default: + current = current.parentElement; + } + } + + return null; +} diff --git a/packages/polyfill/source/tests/is-default-namespace.test.ts b/packages/polyfill/source/tests/is-default-namespace.test.ts new file mode 100644 index 00000000..79a6e643 --- /dev/null +++ b/packages/polyfill/source/tests/is-default-namespace.test.ts @@ -0,0 +1,111 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {Attr} from '../Attr.ts'; +import {HTML_NAMESPACE, SVG_NAMESPACE, XMLNS_NAMESPACE} from '../constants.ts'; +import {Window} from '../index.ts'; + +let document: Window['document']; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); + document = window.document; +}); + +describe('Node.isDefaultNamespace()', () => { + it('locates the default namespace for HTML, SVG, documents, and descendants', () => { + const html = document.createElement('div'); + const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); + const text = document.createTextNode('text'); + svg.appendChild(text); + + expect(document.isDefaultNamespace(HTML_NAMESPACE)).toBe(true); + expect(html.isDefaultNamespace(HTML_NAMESPACE)).toBe(true); + expect(html.isDefaultNamespace(SVG_NAMESPACE)).toBe(false); + expect(html.isDefaultNamespace(null)).toBe(false); + expect(html.isDefaultNamespace('')).toBe(false); + + expect(svg.isDefaultNamespace(SVG_NAMESPACE)).toBe(true); + expect(svg.isDefaultNamespace(HTML_NAMESPACE)).toBe(false); + expect(svg.isDefaultNamespace(null)).toBe(false); + expect(text.isDefaultNamespace(SVG_NAMESPACE)).toBe(true); + }); + + it('converts the namespace before resolving the receiver context', () => { + const html = document.createElement('div'); + const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); + const text = document.createTextNode('text'); + html.appendChild(text); + let conversions = 0; + + const result = text.isDefaultNamespace({ + toString() { + conversions++; + svg.appendChild(text); + return HTML_NAMESPACE; + }, + } as any); + + expect(result).toBe(false); + expect(conversions).toBe(1); + expect(text.parentNode).toBe(svg); + }); + + it('walks deeply nested namespace contexts without recursion', () => { + let current = document.createElement('div'); + for (let index = 0; index < 10_000; index++) { + const next = document.createElementNS('urn:own', 'p:item'); + current.appendChild(next); + current = next; + } + + expect(current.isDefaultNamespace(HTML_NAMESPACE)).toBe(true); + }, 20_000); + + it('uses default xmlns declarations for prefixed elements', () => { + const svg = document.createElementNS(SVG_NAMESPACE, 'svg:svg'); + + expect(svg.isDefaultNamespace(SVG_NAMESPACE)).toBe(false); + expect(svg.isDefaultNamespace(null)).toBe(true); + + svg.setAttributeNS(XMLNS_NAMESPACE, 'xmlns', SVG_NAMESPACE); + + expect(svg.isDefaultNamespace(SVG_NAMESPACE)).toBe(true); + expect(svg.isDefaultNamespace(null)).toBe(false); + }); + + it('inherits and overrides default xmlns declarations from ancestors', () => { + const parent = document.createElementNS('urn:parent', 'parent:parent'); + const child = document.createElementNS('urn:child', 'child:child'); + parent.setAttributeNS(XMLNS_NAMESPACE, 'xmlns', 'urn:parent-default'); + parent.appendChild(child); + + expect(child.isDefaultNamespace('urn:parent-default')).toBe(true); + + child.setAttributeNS(XMLNS_NAMESPACE, 'xmlns', 'urn:child-default'); + + expect(child.isDefaultNamespace('urn:child-default')).toBe(true); + expect(child.isDefaultNamespace('urn:parent-default')).toBe(false); + }); + + it('treats an empty default xmlns declaration as null', () => { + const svg = document.createElementNS(SVG_NAMESPACE, 'svg:svg'); + svg.setAttributeNS(XMLNS_NAMESPACE, 'xmlns', ''); + + expect(svg.isDefaultNamespace(null)).toBe(true); + expect(svg.isDefaultNamespace('')).toBe(true); + expect(svg.isDefaultNamespace(SVG_NAMESPACE)).toBe(false); + }); + + it('uses an attached attribute owner as namespace context', () => { + const html = document.createElement('div'); + html.setAttribute('data-example', 'value'); + const attached = html.attributes.getNamedItem('data-example')!; + const detached = new Attr('data-example', 'value'); + + expect(attached.isDefaultNamespace(HTML_NAMESPACE)).toBe(true); + expect(attached.isDefaultNamespace(null)).toBe(false); + expect(detached.isDefaultNamespace(null)).toBe(true); + expect(detached.isDefaultNamespace(HTML_NAMESPACE)).toBe(false); + }); +});