Skip to content
Merged
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/respect-default-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@remote-dom/polyfill': patch
---

Resolve `Node.isDefaultNamespace()` with DOM locate-a-namespace semantics.
56 changes: 53 additions & 3 deletions packages/polyfill/source/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
111 changes: 111 additions & 0 deletions packages/polyfill/source/tests/is-default-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading