From 27ce2db8d13b4207d65b1be4f64c9a447303f8cf Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:47:03 -0400 Subject: [PATCH 1/5] Add the DOMRenderer interface for every DOM operation of the VM The VM called the DOM in two ways. Some calls went through `DOMTreeConstruction` and `DOMChanges`. About 100 other calls read or wrote a member of a node directly, for example `node.nextSibling` and `element.setAttribute()`. `DOMRenderer` is one interface with every operation that the VM does on the output tree. `DOMRendererImpl` is its implementation for a browser document and for a SimpleDOM document. All code in `@glimmer/runtime` and `@glimmer/node` now calls the interface, and nodes are only handles there. A different output, for example a terminal, can implement the same interface on its own node objects. The new test does that with plain objects that have no DOM member. Changes to private API: - `Environment#dom` replaces `getDOM()` and `getAppendOperations()`. - `EnvironmentOptions#dom` replaces `appendOperations` and `updateOperations`. - `DOMRendererImpl` replaces `DOMTreeConstruction` and `DOMChanges`. - `NodeDOMRenderer` replaces `NodeDOMTreeConstruction`. - `InternalModifierManager#create` gets the environment as argument 5. The `on` modifier needs it for the listeners. - `normalizeProperty`, `dynamicAttribute`, `clear`, and `move` take the renderer as argument 1. - `sanitizeAttributeValue` takes the tag name, not the element. Co-Authored-By: Claude Opus 5.5 --- packages/@ember/-internals/glimmer/index.ts | 2 +- .../-internals/glimmer/lib/base-renderer.ts | 18 +- .../-internals/glimmer/lib/component.ts | 6 +- packages/@ember/-internals/glimmer/lib/dom.ts | 5 +- .../-internals/glimmer/tests/utils/helpers.js | 1 - .../integration-tests/lib/modes/node/env.ts | 6 +- .../lib/suites/custom-dom-helper.ts | 6 +- .../integration-tests/test/attributes-test.ts | 7 +- .../test/dom-renderer-test.ts | 361 ++++++++++++++++++ packages/@glimmer/interfaces/index.d.ts | 2 +- .../interfaces/lib/dom/attributes.d.ts | 6 +- .../@glimmer/interfaces/lib/dom/changes.d.ts | 26 -- .../@glimmer/interfaces/lib/dom/renderer.d.ts | 140 +++++++ .../lib/managers/internal/modifier.d.ts | 5 +- .../interfaces/lib/runtime/environment.d.ts | 15 +- .../@glimmer/interfaces/lib/runtime/vm.d.ts | 4 +- .../@glimmer/manager/test/managers-test.ts | 4 +- packages/@glimmer/node/index.ts | 2 +- packages/@glimmer/node/lib/node-dom-helper.ts | 4 +- .../@glimmer/node/lib/serialize-builder.ts | 33 +- packages/@glimmer/runtime/index.ts | 8 +- packages/@glimmer/runtime/lib/bounds.ts | 25 +- .../runtime/lib/compiled/opcodes/content.ts | 17 +- .../runtime/lib/compiled/opcodes/dom.ts | 6 +- .../runtime/lib/compiled/opcodes/lists.ts | 2 +- .../runtime/lib/compiled/opcodes/vm.ts | 14 +- packages/@glimmer/runtime/lib/dom/api.ts | 31 -- packages/@glimmer/runtime/lib/dom/helper.ts | 81 ---- .../@glimmer/runtime/lib/dom/normalize.ts | 26 +- .../@glimmer/runtime/lib/dom/operations.ts | 145 ------- packages/@glimmer/runtime/lib/dom/props.ts | 10 +- packages/@glimmer/runtime/lib/dom/renderer.ts | 337 ++++++++++++++++ .../runtime/lib/dom/sanitized-values.ts | 6 +- packages/@glimmer/runtime/lib/environment.ts | 29 +- packages/@glimmer/runtime/lib/modifiers/on.ts | 52 ++- .../runtime/lib/vm/attributes/dynamic.ts | 70 ++-- .../@glimmer/runtime/lib/vm/content/text.ts | 7 +- .../runtime/lib/vm/element-builder.ts | 45 +-- .../runtime/lib/vm/rehydrate-builder.ts | 248 ++++++------ .../@glimmer/runtime/lib/vm/render-result.ts | 2 +- packages/@glimmer/runtime/lib/vm/update.ts | 21 +- 41 files changed, 1181 insertions(+), 654 deletions(-) create mode 100644 packages/@glimmer-workspace/integration-tests/test/dom-renderer-test.ts delete mode 100644 packages/@glimmer/interfaces/lib/dom/changes.d.ts create mode 100644 packages/@glimmer/interfaces/lib/dom/renderer.d.ts delete mode 100644 packages/@glimmer/runtime/lib/dom/api.ts delete mode 100644 packages/@glimmer/runtime/lib/dom/operations.ts create mode 100644 packages/@glimmer/runtime/lib/dom/renderer.ts diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index a11e878d169..694d66d85c6 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -152,7 +152,7 @@ export { type TemplatesRegistry, } from './lib/template_registry'; export { setupEngineRegistry, setupApplicationRegistry } from './lib/setup-registry'; -export { DOMChanges, NodeDOMTreeConstruction, DOMTreeConstruction } from './lib/dom'; +export { DOMRendererImpl, NodeDOMRenderer } from './lib/dom'; // needed for test // TODO just test these through public API diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 5776f3831ed..0564ba047dd 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -13,6 +13,7 @@ import { DEBUG } from '@glimmer/env'; import type { Cursor, DebugRenderTree, + DOMRenderer, Environment, RenderResult as GlimmerRenderResult, EvaluationContext, @@ -708,8 +709,6 @@ function rendererFor(owner: object, env: RenderComponentEnv | undefined): BaseRe const isCursor = (into: IntoTarget): into is Cursor => 'element' in into; -const isDOMElement = (into: IntoTarget): into is Element => 'innerHTML' in into; - function toCursor(into: IntoTarget): Cursor { return isCursor(into) ? into : { element: into as SimpleElement, nextSibling: null }; } @@ -734,7 +733,11 @@ function toCursor(into: IntoTarget): Cursor { * That render clears the element. * A `Cursor` target is never cleared. */ -function replaceLastRender(into: IntoTarget, element: Element | SimpleElement): IntoTarget { +function replaceLastRender( + dom: DOMRenderer, + into: IntoTarget, + element: Element | SimpleElement +): IntoTarget { let last = LAST_RENDER_INTO.get(element); if (last?.glimmerResult) { @@ -743,8 +746,8 @@ function replaceLastRender(into: IntoTarget, element: Element | SimpleElement): return { element: element as SimpleElement, nextSibling: last.glimmerResult.firstNode() }; } - if (!last && isDOMElement(into)) { - into.innerHTML = ''; + if (!last && !isCursor(into) && dom.hasProperty(into as SimpleElement, 'innerHTML')) { + dom.clearChildren(into as SimpleElement); } return into; @@ -805,7 +808,10 @@ export function renderComponent( let renderer = rendererFor(owner, env); let element = isCursor(into) ? into.element : into; - let root = renderer.render(component, { into: replaceLastRender(into, element), args }); + let root = renderer.render(component, { + into: replaceLastRender(renderer.state.env.dom, into, element), + args, + }); let glimmerResult = root.result; /** diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index 490a6595227..bb813831867 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -1098,7 +1098,11 @@ class Component let element = _element; let isSVG = element.namespaceURI === 'http://www.w3.org/2000/svg'; - let { type, normalized } = normalizeProperty(element as unknown as SimpleElement, name); + let { type, normalized } = normalizeProperty( + this.renderer._context.env.dom, + element as unknown as SimpleElement, + name + ); if (isSVG || type === 'attr') { return element.getAttribute(normalized); diff --git a/packages/@ember/-internals/glimmer/lib/dom.ts b/packages/@ember/-internals/glimmer/lib/dom.ts index d9b07fe2c22..127e0186033 100644 --- a/packages/@ember/-internals/glimmer/lib/dom.ts +++ b/packages/@ember/-internals/glimmer/lib/dom.ts @@ -1,6 +1,5 @@ -export { DOMChanges } from '@glimmer/runtime/lib/dom/helper'; -export { DOMTreeConstruction } from '@glimmer/runtime/lib/dom/api'; +export { DOMRendererImpl } from '@glimmer/runtime/lib/dom/renderer'; export { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; export { rehydrationBuilder } from '@glimmer/runtime/lib/vm/rehydrate-builder'; -export { default as NodeDOMTreeConstruction } from '@glimmer/node/lib/node-dom-helper'; +export { default as NodeDOMRenderer } from '@glimmer/node/lib/node-dom-helper'; export { serializeBuilder } from '@glimmer/node/lib/serialize-builder'; diff --git a/packages/@ember/-internals/glimmer/tests/utils/helpers.js b/packages/@ember/-internals/glimmer/tests/utils/helpers.js index 7a213c94bee..e389d96c155 100644 --- a/packages/@ember/-internals/glimmer/tests/utils/helpers.js +++ b/packages/@ember/-internals/glimmer/tests/utils/helpers.js @@ -6,6 +6,5 @@ export { Component, htmlSafe, SafeString, - DOMChanges, isHTMLSafe, } from '@ember/-internals/glimmer'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts index 852335ee0e8..1583e6a77a1 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/node/env.ts @@ -1,8 +1,7 @@ import type { Cursor, + DOMRenderer, Environment, - GlimmerTreeChanges, - GlimmerTreeConstruction, SimpleDocument, TreeBuilder, } from '@glimmer/interfaces'; @@ -18,8 +17,7 @@ import { JitRenderDelegate } from '../jit/delegate'; export interface NodeEnvironmentOptions { document: SimpleDocument; - appendOperations?: GlimmerTreeConstruction; - updateOperations?: GlimmerTreeChanges; + dom?: DOMRenderer; } export class NodeJitRenderDelegate extends JitRenderDelegate { diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts index 1323618423e..e30a4458637 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/custom-dom-helper.ts @@ -1,5 +1,5 @@ import { precompile } from '@glimmer/compiler'; -import { NodeDOMTreeConstruction } from '@glimmer/node'; +import { NodeDOMRenderer } from '@glimmer/node'; import { blockStack } from '../dom/blocks'; import { toInnerHTML } from '../dom/simple-utils'; @@ -13,10 +13,10 @@ export class DOMHelperTests extends AbstractNodeTest { static suiteName = 'Server-side rendering in Node.js (normal)'; @test - 'can instantiate NodeDOMTreeConstruction without a document'() { + 'can instantiate NodeDOMRenderer without a document'() { // this emulates what happens in Ember when using `App.visit('/', { shouldRender: false });` - let helper = new NodeDOMTreeConstruction(null as any); + let helper = new NodeDOMRenderer(null as any); this.assert.ok(!!helper, 'helper was instantiated without errors'); } diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index a0966a72a02..763d1699459 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -1,8 +1,8 @@ import { tracked } from '@glimmer/tracking'; import type { SimpleElement } from '@glimmer/interfaces'; import { NS_SVG } from '@glimmer/constants'; -import { castToBrowser, expect } from '@glimmer/debug-util'; -import { normalizeProperty } from '@glimmer/runtime'; +import { castToBrowser, castToSimple, expect } from '@glimmer/debug-util'; +import { DOMRendererImpl, normalizeProperty } from '@glimmer/runtime'; import { assertingElement, hasAttribute, @@ -16,7 +16,8 @@ export class AttributesTests extends RenderTest { protected readDOMAttr(attr: string, element = this.element.firstChild as SimpleElement) { const isSVG = element.namespaceURI === NS_SVG; - const { type, normalized } = normalizeProperty(element, attr); + const dom = new DOMRendererImpl(castToSimple(document)); + const { type, normalized } = normalizeProperty(dom, element, attr); if (isSVG) { return element.getAttribute(normalized); diff --git a/packages/@glimmer-workspace/integration-tests/test/dom-renderer-test.ts b/packages/@glimmer-workspace/integration-tests/test/dom-renderer-test.ts new file mode 100644 index 00000000000..2b21bf9b1dd --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/dom-renderer-test.ts @@ -0,0 +1,361 @@ +import type { + Bounds, + DOMRenderer, + Nullable, + RenderResult, + SimpleComment, + SimpleDocumentFragment, + SimpleElement, + SimpleNode, + SimpleText, +} from '@glimmer/interfaces'; +import { on } from '@ember/modifier'; +import { run } from '@ember/runloop'; +import { template } from '@ember/template-compiler/runtime'; +import { EvaluationContextImpl } from '@glimmer/opcode-compiler'; +import { artifacts, RuntimeOpImpl } from '@glimmer/program'; +import { + clientBuilder, + ConcreteBounds, + inTransaction, + renderComponent, + runtimeOptions, +} from '@glimmer/runtime'; +import { tracked } from '@glimmer/tracking'; +import ResolverImpl from '@ember/-internals/glimmer/lib/resolver'; + +type Kind = 'element' | 'text' | 'comment'; + +/** + * A node that is not a DOM node. + * It has none of the members of `Node`, + * so a direct DOM access by the VM fails on it. + */ +class TreeNode { + parent: TreeNode | null = null; + children: TreeNode[] = []; + attrs = new Map(); + props = new Map(); + listeners = new Map(); + + constructor( + readonly kind: Kind, + public value: string + ) {} +} + +const node = (value: SimpleNode) => value as unknown as TreeNode; +const simple = (value: TreeNode | null) => value as unknown as T; + +class TreeRenderer implements DOMRenderer { + createElement(tag: string): SimpleElement { + return simple(new TreeNode('element', tag)); + } + + createElementNS(_namespace: unknown, tag: string): SimpleElement { + return this.createElement(tag); + } + + createTextNode(text: string): SimpleText { + return simple(new TreeNode('text', text)); + } + + createComment(data: string): SimpleComment { + return simple(new TreeNode('comment', data)); + } + + insertBefore(parent: SimpleElement, child: SimpleNode, reference: Nullable): void { + let target = node(parent); + let inserted = node(child); + + if (inserted.parent) { + this.removeChild(simple(inserted.parent), child); + } + + let index = reference === null ? target.children.length : this.indexOf(reference); + + target.children.splice(index, 0, inserted); + inserted.parent = target; + } + + insertHTMLBefore(parent: SimpleElement, reference: Nullable, html: string): Bounds { + let text = this.createTextNode(html); + + this.insertBefore(parent, text, reference); + + return new ConcreteBounds(parent, text, text); + } + + removeChild(parent: SimpleElement, child: SimpleNode): void { + node(parent).children.splice(this.indexOf(child), 1); + node(child).parent = null; + } + + clearChildren(element: SimpleElement): void { + for (let child of node(element).children) { + child.parent = null; + } + + node(element).children = []; + } + + parentNode(child: SimpleNode): Nullable { + return simple(node(child).parent); + } + + firstChild(parent: SimpleNode): Nullable { + return simple(node(parent).children[0] ?? null); + } + + lastChild(parent: SimpleNode): Nullable { + let { children } = node(parent); + + return simple(children[children.length - 1] ?? null); + } + + nextSibling(child: SimpleNode): Nullable { + return this.sibling(child, 1); + } + + previousSibling(child: SimpleNode): Nullable { + return this.sibling(child, -1); + } + + querySelector(): Nullable { + return null; + } + + isNode(value: unknown): value is SimpleNode { + return value instanceof TreeNode; + } + + isFragment(_value: unknown): _value is SimpleDocumentFragment { + return false; + } + + isElement(value: SimpleNode): value is SimpleElement { + return node(value).kind === 'element'; + } + + isText(value: SimpleNode): value is SimpleText { + return node(value).kind === 'text'; + } + + isComment(value: SimpleNode): value is SimpleComment { + return node(value).kind === 'comment'; + } + + tagName(element: SimpleElement): string { + return node(element).value; + } + + namespaceURI(): Nullable { + return null; + } + + nodeValue(value: SimpleNode): Nullable { + return node(value).value; + } + + setNodeValue(target: SimpleNode, value: string): void { + node(target).value = value; + } + + attributeNames(element: SimpleElement): string[] { + return Array.from(node(element).attrs.keys()); + } + + getAttribute(element: SimpleElement, name: string): Nullable { + return node(element).attrs.get(name) ?? null; + } + + setAttribute(element: SimpleElement, name: string, value: string): void { + node(element).attrs.set(name, value); + } + + removeAttribute(element: SimpleElement, name: string): void { + node(element).attrs.delete(name); + } + + hasProperty(element: SimpleElement, name: string): boolean { + return node(element).props.has(name); + } + + getProperty(element: SimpleElement, name: string): unknown { + return node(element).props.get(name); + } + + setProperty(element: SimpleElement, name: string, value: unknown): void { + node(element).props.set(name, value); + } + + addEventListener(element: SimpleElement, eventName: string, callback: EventListener): void { + let { listeners } = node(element); + + listeners.set(eventName, (listeners.get(eventName) ?? []).concat(callback)); + } + + removeEventListener(element: SimpleElement, eventName: string, callback: EventListener): void { + let { listeners } = node(element); + + listeners.set( + eventName, + (listeners.get(eventName) ?? []).filter((listener) => listener !== callback) + ); + } + + private indexOf(child: SimpleNode): number { + let { parent } = node(child); + + return parent ? parent.children.indexOf(node(child)) : -1; + } + + private sibling(child: SimpleNode, offset: number): Nullable { + let { parent } = node(child); + + return simple(parent?.children[this.indexOf(child) + offset] ?? null); + } +} + +function print(target: TreeNode): string { + if (target.kind === 'text') return target.value; + if (target.kind === 'comment') return ``; + + let attrs = ''; + + for (let [name, value] of target.attrs) { + attrs += ` ${name}="${value}"`; + } + + return `<${target.value}${attrs}>${target.children.map(print).join('')}`; +} + +function find(target: TreeNode, tag: string): TreeNode | undefined { + if (target.kind === 'element' && target.value === tag) return target; + + for (let child of target.children) { + let found = find(child, tag); + + if (found) return found; + } + + return undefined; +} + +function setup(component: object) { + let dom = new TreeRenderer(); + let sharedArtifacts = artifacts(); + let context = new EvaluationContextImpl( + sharedArtifacts, + (heap) => new RuntimeOpImpl(heap), + runtimeOptions( + { dom }, + { isInteractive: true, enableDebugTooling: false, onTransactionCommit() {} }, + sharedArtifacts, + new ResolverImpl() + ) + ); + let { env } = context; + let root = node(dom.createElement('root')); + let result: RenderResult | undefined; + + inTransaction(env, () => { + result = renderComponent( + context, + clientBuilder(env, { element: simple(root), nextSibling: null }), + {}, + component + ).sync(); + }); + + return { + root, + html: () => root.children.map(print).join(''), + /** + * The run loop runs the destructors of the removed content before `rerender` returns. + */ + rerender: () => run(() => inTransaction(env, () => result?.rerender())), + }; +} + +QUnit.module('[integration] DOMRenderer that is not a DOM'); + +QUnit.test('it renders and updates elements, attributes, and text', (assert) => { + class State { + @tracked title = 'one'; + @tracked name = 'Zoey'; + } + + let state = new State(); + let { html, rerender } = setup( + template('Hello, {{state.name}}!', { + strictMode: true, + scope: () => ({ state }), + }) + ); + + assert.strictEqual(html(), 'Hello, Zoey!'); + + state.title = 'two'; + state.name = 'Tomster'; + rerender(); + + assert.strictEqual(html(), 'Hello, Tomster!'); +}); + +QUnit.test('it renders and updates blocks', (assert) => { + class State { + @tracked show = true; + @tracked items = ['a', 'b', 'c']; + } + + let state = new State(); + let { html, rerender } = setup( + template( + '{{#if state.show}}{{else}}{{/if}}' + + '{{#each state.items key="@identity" as |item|}}{{item}}{{/each}}', + { strictMode: true, scope: () => ({ state }) } + ) + ); + + assert.strictEqual(html(), 'abc'); + + state.show = false; + state.items = ['c', 'a', 'd']; + rerender(); + + assert.strictEqual(html(), 'cad'); + + state.items = []; + rerender(); + + assert.strictEqual(html(), ''); +}); + +QUnit.test('the `on` modifier uses the listeners of the renderer', (assert) => { + class State { + @tracked show = true; + } + + let state = new State(); + let press = () => assert.step('press'); + let { root, rerender } = setup( + template('{{#if state.show}}{{/if}}', { + strictMode: true, + scope: () => ({ state, on, press }), + }) + ); + + let button = find(root, 'button'); + let listeners = () => button?.listeners.get('press') ?? []; + + assert.strictEqual(listeners().length, 1, 'the render adds the listener'); + + listeners()[0]?.(new Event('press')); + assert.verifySteps(['press']); + + state.show = false; + rerender(); + + assert.strictEqual(listeners().length, 0, 'the removal of the element removes the listener'); +}); diff --git a/packages/@glimmer/interfaces/index.d.ts b/packages/@glimmer/interfaces/index.d.ts index 0773e438e4d..382c370dd71 100644 --- a/packages/@glimmer/interfaces/index.d.ts +++ b/packages/@glimmer/interfaces/index.d.ts @@ -8,7 +8,7 @@ export type * from './lib/core.d.ts'; export type * from './lib/curry.d.ts'; export type * from './lib/dom/attributes.d.ts'; export type * from './lib/dom/bounds.d.ts'; -export type * from './lib/dom/changes.d.ts'; +export type * from './lib/dom/renderer.d.ts'; export type * from './lib/dom/simple.d.ts'; export type * from './lib/dom/tree-construction.d.ts'; export type * from './lib/managers.d.ts'; diff --git a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts index 47919a1e7ac..e5ff5f20bfd 100644 --- a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts +++ b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts @@ -2,7 +2,7 @@ import type { Maybe, Nullable } from '../core.js'; import type { ElementOperations, Environment, ModifierInstance } from '../runtime.js'; import type { Stack } from '../stack.js'; import type { Bounds, Cursor } from './bounds.js'; -import type { GlimmerTreeChanges, GlimmerTreeConstruction } from './changes.js'; +import type { DOMRenderer } from './renderer.js'; import type { AttrNamespace, SimpleComment, @@ -98,8 +98,7 @@ export interface TreeBuilder extends Cursor, DOMStack, TreeOperations { }; nextSibling: Nullable; - dom: GlimmerTreeConstruction; - updateOperations: GlimmerTreeChanges; + dom: DOMRenderer; constructing: Nullable; element: SimpleElement; @@ -118,6 +117,7 @@ export interface AttributeCursor { element: SimpleElement; name: string; namespace: Nullable; + tagName: string; } export interface AttributeOperation { diff --git a/packages/@glimmer/interfaces/lib/dom/changes.d.ts b/packages/@glimmer/interfaces/lib/dom/changes.d.ts deleted file mode 100644 index a847cbeeb36..00000000000 --- a/packages/@glimmer/interfaces/lib/dom/changes.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Nullable } from '../core.js'; -import type { Bounds } from './bounds.js'; -import type { Namespace, SimpleComment, SimpleElement, SimpleNode, SimpleText } from './simple.js'; - -export interface GlimmerDOMOperations { - createElement(tag: string, context?: SimpleElement): SimpleElement; - insertBefore(parent: SimpleElement, node: SimpleNode, reference: Nullable): void; - insertHTMLBefore(parent: SimpleElement, nextSibling: Nullable, html: string): Bounds; - createTextNode(text: string): SimpleText; - createComment(data: string): SimpleComment; -} - -export interface GlimmerTreeChanges extends GlimmerDOMOperations { - setAttribute(element: SimpleElement, name: string, value: string): void; - removeAttribute(element: SimpleElement, name: string): void; - insertAfter(element: SimpleElement, node: SimpleNode, reference: SimpleNode): void; -} - -export interface GlimmerTreeConstruction extends GlimmerDOMOperations { - setAttribute( - element: SimpleElement, - name: string, - value: string, - namespace?: Nullable - ): void; -} diff --git a/packages/@glimmer/interfaces/lib/dom/renderer.d.ts b/packages/@glimmer/interfaces/lib/dom/renderer.d.ts new file mode 100644 index 00000000000..4d920b7cdb8 --- /dev/null +++ b/packages/@glimmer/interfaces/lib/dom/renderer.d.ts @@ -0,0 +1,140 @@ +import type { Nullable } from '../core.js'; +import type { Bounds } from './bounds.js'; +import type { + AttrNamespace, + ElementNamespace, + SimpleComment, + SimpleDocumentFragment, + SimpleElement, + SimpleNode, + SimpleText, +} from './simple.js'; + +/** + * Every operation that the VM does on the output tree. + * + * The VM holds nodes only as handles. + * It gets them from this interface, and it gives them back to this interface. + * It does not read or write a member of a node. + * + * An implementation for a different output, for example a terminal, + * implements the same methods on its own node objects. + * + * Not part of this interface: + * + * - how to read a value from an object (`getProp`, `toBool`, `toIterator`) + * - how to find a component, helper, or modifier + * - managers + * + * These are the same for each output. + */ +export interface DOMRenderer { + /** + * --------------------------------------------------------------------------- + * Create + * --------------------------------------------------------------------------- + */ + + /** + * `context` is the element that will contain the new element. + * The implementation uses it to select the namespace. + */ + createElement(tag: string, context?: SimpleElement): SimpleElement; + createElementNS(namespace: ElementNamespace, tag: string): SimpleElement; + createTextNode(text: string): SimpleText; + createComment(data: string): SimpleComment; + + /** + * --------------------------------------------------------------------------- + * Change the tree + * --------------------------------------------------------------------------- + */ + + /** + * A `reference` of `null` appends `node` to `parent`. + */ + insertBefore(parent: SimpleElement, node: SimpleNode, reference: Nullable): void; + + /** + * Parses `html` and inserts the result. + * The returned bounds are never empty. + */ + insertHTMLBefore(parent: SimpleElement, reference: Nullable, html: string): Bounds; + removeChild(parent: SimpleElement, node: SimpleNode): void; + clearChildren(element: SimpleElement): void; + + /** + * --------------------------------------------------------------------------- + * Read the tree + * --------------------------------------------------------------------------- + */ + + parentNode(node: SimpleNode): Nullable; + firstChild(node: SimpleNode): Nullable; + lastChild(node: SimpleNode): Nullable; + nextSibling(node: SimpleNode): Nullable; + previousSibling(node: SimpleNode): Nullable; + querySelector(element: SimpleElement, selector: string): Nullable; + + /** + * --------------------------------------------------------------------------- + * Read a node + * --------------------------------------------------------------------------- + */ + + /** + * `isNode` and `isFragment` take any value. + * The VM uses them on values that a template renders with `{{value}}`. + */ + isNode(value: unknown): value is SimpleNode; + isFragment(value: unknown): value is SimpleDocumentFragment; + isElement(node: SimpleNode): node is SimpleElement; + isText(node: SimpleNode): node is SimpleText; + isComment(node: SimpleNode): node is SimpleComment; + tagName(element: SimpleElement): string; + namespaceURI(element: SimpleElement): Nullable; + + /** + * The text of a text node or of a comment. + */ + nodeValue(node: SimpleNode): Nullable; + setNodeValue(node: SimpleNode, value: string): void; + + /** + * --------------------------------------------------------------------------- + * Attributes and properties + * --------------------------------------------------------------------------- + */ + + attributeNames(element: SimpleElement): string[]; + getAttribute(element: SimpleElement, name: string): Nullable; + setAttribute( + element: SimpleElement, + name: string, + value: string, + namespace?: Nullable + ): void; + removeAttribute(element: SimpleElement, name: string, namespace?: Nullable): void; + hasProperty(element: SimpleElement, name: string): boolean; + getProperty(element: SimpleElement, name: string): unknown; + setProperty(element: SimpleElement, name: string, value: unknown): void; + + /** + * --------------------------------------------------------------------------- + * Events + * --------------------------------------------------------------------------- + */ + + addEventListener( + element: SimpleElement, + eventName: string, + callback: EventListener, + options?: AddEventListenerOptions + ): void; + removeEventListener( + element: SimpleElement, + eventName: string, + callback: EventListener, + options?: AddEventListenerOptions + ): void; +} diff --git a/packages/@glimmer/interfaces/lib/managers/internal/modifier.d.ts b/packages/@glimmer/interfaces/lib/managers/internal/modifier.d.ts index b717072d23f..0060c6f3e92 100644 --- a/packages/@glimmer/interfaces/lib/managers/internal/modifier.d.ts +++ b/packages/@glimmer/interfaces/lib/managers/internal/modifier.d.ts @@ -1,7 +1,7 @@ import type { SimpleElement } from '@simple-dom/interface'; import type { Destroyable } from '../../core.js'; -import type { Owner } from '../../runtime.js'; +import type { Environment, Owner } from '../../runtime.js'; import type { CapturedArguments } from '../../runtime/arguments.js'; import type { ModifierDefinitionState, ModifierInstanceState } from '../../runtime/modifier.js'; import type { UpdatableTag } from '../../tags.js'; @@ -15,7 +15,8 @@ export interface InternalModifierManager< owner: Owner, element: SimpleElement, state: TModifierDefinitionState, - args: CapturedArguments + args: CapturedArguments, + env: Environment ): TModifierInstanceState; // Convert the opaque modifier into a `RevisionTag` that determins when diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index c47f1c98abb..d4e9e387ed4 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -6,7 +6,7 @@ import type { ComponentInstanceState, } from '../components.js'; import type { Nullable } from '../core.js'; -import type { GlimmerTreeChanges, GlimmerTreeConstruction } from '../dom/changes.js'; +import type { DOMRenderer } from '../dom/renderer.js'; import type { WithCreateInstance } from '../managers.js'; import type { ClassicResolver } from '../program.js'; import type { DebugRenderTree } from './debug-render-tree.js'; @@ -14,9 +14,12 @@ import type { ModifierInstance } from './modifier.js'; import type { Program } from './runtime.js'; export interface EnvironmentOptions { + /** + * The environment makes a `DOMRenderer` for this document. + * `dom` has priority over `document`. + */ document?: SimpleDocument; - appendOperations?: GlimmerTreeConstruction; - updateOperations?: GlimmerTreeChanges; + dom?: DOMRenderer; } export type Transaction = object; @@ -42,8 +45,10 @@ export interface Environment { begin(): void; commit(): void; - getDOM(): GlimmerTreeChanges; - getAppendOperations(): GlimmerTreeConstruction; + /** + * Every operation on the output tree goes through this object. + */ + readonly dom: DOMRenderer; isInteractive: boolean; debugRenderTree?: DebugRenderTree | undefined; diff --git a/packages/@glimmer/interfaces/lib/runtime/vm.d.ts b/packages/@glimmer/interfaces/lib/runtime/vm.d.ts index 8dec8feae6e..834f0b33eed 100644 --- a/packages/@glimmer/interfaces/lib/runtime/vm.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/vm.d.ts @@ -1,11 +1,11 @@ import type { Bounds } from '../dom/bounds.js'; -import type { GlimmerTreeChanges } from '../dom/changes.js'; +import type { DOMRenderer } from '../dom/renderer.js'; import type { Environment } from './environment.js'; import type { ExceptionHandler } from './render.js'; export interface UpdatingVM { env: Environment; - dom: GlimmerTreeChanges; + dom: DOMRenderer; alwaysRevalidate: boolean; execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler): void; diff --git a/packages/@glimmer/manager/test/managers-test.ts b/packages/@glimmer/manager/test/managers-test.ts index f00ef53d016..65b8679c741 100644 --- a/packages/@glimmer/manager/test/managers-test.ts +++ b/packages/@glimmer/manager/test/managers-test.ts @@ -338,7 +338,7 @@ module('Managers', () => { let manager = getInternalModifierManager(definition); assert.throws(() => { - manager.create({}, {} as any, {}, {} as any); + manager.create({}, {} as any, {}, {} as any, {} as any); }, /Custom modifier managers must have a `capabilities` property /u); }); @@ -352,7 +352,7 @@ module('Managers', () => { let manager = getInternalModifierManager(definition); assert.throws(() => { - manager.create({}, {} as any, {}, {} as any); + manager.create({}, {} as any, {}, {} as any, {} as any); }, /Custom modifier managers must have a `capabilities` property /u); }); diff --git a/packages/@glimmer/node/index.ts b/packages/@glimmer/node/index.ts index 34308b377ae..f62748e6ecf 100644 --- a/packages/@glimmer/node/index.ts +++ b/packages/@glimmer/node/index.ts @@ -1,2 +1,2 @@ -export { default as NodeDOMTreeConstruction } from './lib/node-dom-helper'; +export { default as NodeDOMRenderer } from './lib/node-dom-helper'; export { serializeBuilder } from './lib/serialize-builder'; diff --git a/packages/@glimmer/node/lib/node-dom-helper.ts b/packages/@glimmer/node/lib/node-dom-helper.ts index 611026dce41..05382f58ac3 100644 --- a/packages/@glimmer/node/lib/node-dom-helper.ts +++ b/packages/@glimmer/node/lib/node-dom-helper.ts @@ -6,10 +6,10 @@ import type { SimpleNode, } from '@glimmer/interfaces'; import { ConcreteBounds } from '@glimmer/runtime/lib/bounds'; -import { DOMTreeConstruction } from '@glimmer/runtime/lib/dom/api'; +import { DOMRendererImpl } from '@glimmer/runtime/lib/dom/renderer'; import createHTMLDocument from '@simple-dom/document'; -export default class NodeDOMTreeConstruction extends DOMTreeConstruction { +export default class NodeDOMRenderer extends DOMRendererImpl { declare protected document: SimpleDocument; // Hides property on base class constructor(doc: Nullable) { super(doc || createHTMLDocument()); diff --git a/packages/@glimmer/node/lib/serialize-builder.ts b/packages/@glimmer/node/lib/serialize-builder.ts index b75499cb299..72cf80edf80 100644 --- a/packages/@glimmer/node/lib/serialize-builder.ts +++ b/packages/@glimmer/node/lib/serialize-builder.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ import type { Bounds, Environment, @@ -14,19 +13,15 @@ import type { RemoteBlock } from '@glimmer/runtime/lib/vm/element-builder'; import { ConcreteBounds } from '@glimmer/runtime/lib/bounds'; import { NewTreeBuilder } from '@glimmer/runtime/lib/vm/element-builder'; -const TEXT_NODE = 3; - const NEEDS_EXTRA_CLOSE = new WeakMap(); -function currentNode( - cursor: TreeBuilder | { element: SimpleElement; nextSibling: SimpleNode } -): Nullable { - let { element, nextSibling } = cursor; +function currentNode(cursor: TreeBuilder): Nullable { + let { dom, element, nextSibling } = cursor; if (nextSibling === null) { - return element.lastChild; + return dom.lastChild(element); } else { - return nextSibling.previousSibling; + return dom.previousSibling(nextSibling); } } @@ -34,7 +29,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { private serializeBlockDepth = 0; override __openBlock(): void { - let { tagName } = this.element; + let tagName = this.dom.tagName(this.element); if (tagName !== 'TITLE' && tagName !== 'SCRIPT' && tagName !== 'STYLE') { let depth = this.serializeBlockDepth++; @@ -45,7 +40,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { } override __closeBlock(): void { - let { tagName } = this.element; + let tagName = this.dom.tagName(this.element); super.__closeBlock(); @@ -56,7 +51,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { } override __appendHTML(html: string): Bounds { - let { tagName } = this.element; + let tagName = this.dom.tagName(this.element); if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') { return super.__appendHTML(html); @@ -84,7 +79,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { } override __appendText(string: string): SimpleText { - let { tagName } = this.element; + let tagName = this.dom.tagName(this.element); let current = currentNode(this); if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') { @@ -92,7 +87,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { } else if (string === '') { // eslint-disable-next-line @typescript-eslint/no-explicit-any return this.__appendComment('% %') as any as SimpleText; - } else if (current && current.nodeType === TEXT_NODE) { + } else if (current && this.dom.isText(current)) { this.__appendComment('%|%'); } @@ -110,11 +105,9 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { override openElement(tag: string) { if (tag === 'tr') { - if ( - this.element.tagName !== 'TBODY' && - this.element.tagName !== 'THEAD' && - this.element.tagName !== 'TFOOT' - ) { + let parentTag = this.dom.tagName(this.element); + + if (parentTag !== 'TBODY' && parentTag !== 'THEAD' && parentTag !== 'TFOOT') { this.openElement('tbody'); // This prevents the closeBlock comment from being re-parented // under the auto inserted tbody. Rehydration builder needs to @@ -136,7 +129,7 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder { ): RemoteBlock { let { dom } = this; let script = dom.createElement('script'); - script.setAttribute('glmr', cursorId); + dom.setAttribute(script, 'glmr', cursorId); dom.insertBefore(element, script, insertBefore); return super.pushRemoteElement(element, cursorId, insertBefore); } diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index 72e95d58c30..b315bc76cc2 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -13,12 +13,8 @@ export { TemplateOnlyComponentManager, } from './lib/component/template-only'; export { CurriedValue, curry } from './lib/curried-value'; -export { - DOMChanges, - DOMTreeConstruction, - DOMChangesImpl as IDOMChanges, - isWhitespace, -} from './lib/dom/helper'; +export { isWhitespace } from './lib/dom/helper'; +export { DOMRendererImpl } from './lib/dom/renderer'; export { normalizeProperty } from './lib/dom/props'; export { type EnvironmentDelegate, diff --git a/packages/@glimmer/runtime/lib/bounds.ts b/packages/@glimmer/runtime/lib/bounds.ts index 224a45cb067..4ecaab39d86 100644 --- a/packages/@glimmer/runtime/lib/bounds.ts +++ b/packages/@glimmer/runtime/lib/bounds.ts @@ -1,4 +1,11 @@ -import type { Bounds, Cursor, Nullable, SimpleElement, SimpleNode } from '@glimmer/interfaces'; +import type { + Bounds, + Cursor, + DOMRenderer, + Nullable, + SimpleElement, + SimpleNode, +} from '@glimmer/interfaces'; import { expect } from '@glimmer/debug-util/lib/platform-utils'; import { setLocalDebugType } from '@glimmer/debug-util/lib/debug-brand'; @@ -33,7 +40,11 @@ export class ConcreteBounds implements Bounds { } } -export function move(bounds: Bounds, reference: Nullable): Nullable { +export function move( + dom: DOMRenderer, + bounds: Bounds, + reference: Nullable +): Nullable { let parent = bounds.parentElement(); let first = bounds.firstNode(); let last = bounds.lastNode(); @@ -41,9 +52,9 @@ export function move(bounds: Bounds, reference: Nullable): Nullable< let current: SimpleNode = first; while (true) { - let next = current.nextSibling; + let next = dom.nextSibling(current); - parent.insertBefore(current, reference); + dom.insertBefore(parent, current, reference); if (current === last) { return next; @@ -53,7 +64,7 @@ export function move(bounds: Bounds, reference: Nullable): Nullable< } } -export function clear(bounds: Bounds): Nullable { +export function clear(dom: DOMRenderer, bounds: Bounds): Nullable { let parent = bounds.parentElement(); let first = bounds.firstNode(); let last = bounds.lastNode(); @@ -61,9 +72,9 @@ export function clear(bounds: Bounds): Nullable { let current: SimpleNode = first; while (true) { - let next = current.nextSibling; + let next = dom.nextSibling(current); - parent.removeChild(current); + dom.removeChild(parent, current); if (current === last) { return next; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts index 402339a8d69..dea6da7ce55 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts @@ -1,4 +1,5 @@ import { DEBUG } from '@glimmer/env'; +import type { DOMRenderer } from '@glimmer/interfaces'; import { CURRIED_COMPONENT, CURRIED_HELPER } from '@glimmer/constants/lib/curried'; import { VM_APPEND_DOCUMENT_FRAGMENT_OP, @@ -25,13 +26,13 @@ import { isIndexable } from '@glimmer/util/lib/collections'; import { ContentType } from '@glimmer/vm/lib/content'; import { isCurriedType } from '../../curried-value'; -import { isEmpty, isFragment, isNode, isSafeString, shouldCoerce } from '../../dom/normalize'; +import { isEmpty, isSafeString, shouldCoerce } from '../../dom/normalize'; import { APPEND_OPCODES } from '../../opcodes'; import DynamicTextContent from '../../vm/content/text'; import { CheckReference } from './-debug-strip'; import { AssertFilter } from './vm'; -function toContentType(value: unknown) { +function toContentType(value: unknown, dom: DOMRenderer) { if (shouldCoerce(value)) { return ContentType.String; } else if (isCurriedType(value, CURRIED_COMPONENT) || hasInternalComponentManager(value)) { @@ -40,9 +41,9 @@ function toContentType(value: unknown) { return ContentType.Helper; } else if (isSafeString(value)) { return ContentType.SafeString; - } else if (isFragment(value)) { + } else if (dom.isFragment(value)) { return ContentType.Fragment; - } else if (isNode(value)) { + } else if (dom.isNode(value)) { return ContentType.Node; } else { return ContentType.String; @@ -71,10 +72,12 @@ function toDynamicContentType(value: unknown) { APPEND_OPCODES.add(VM_CONTENT_TYPE_OP, (vm) => { let reference = check(vm.stack.peek(), CheckReference); - vm.stack.push(toContentType(valueForRef(reference))); + let { dom } = vm.env; + + vm.stack.push(toContentType(valueForRef(reference), dom)); if (!isConstRef(reference)) { - vm.updateWith(new AssertFilter(reference, toContentType)); + vm.updateWith(new AssertFilter(reference, toContentType, dom)); } }); @@ -84,7 +87,7 @@ APPEND_OPCODES.add(VM_DYNAMIC_CONTENT_TYPE_OP, (vm) => { vm.stack.push(toDynamicContentType(valueForRef(reference))); if (!isConstRef(reference)) { - vm.updateWith(new AssertFilter(reference, toDynamicContentType)); + vm.updateWith(new AssertFilter(reference, toDynamicContentType, vm.env.dom)); } }); diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts index 1976f099867..ed6b9b4dcf4 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts @@ -180,7 +180,8 @@ APPEND_OPCODES.add(VM_MODIFIER_OP, (vm, { op1: handle }) => { owner, expect(constructing, 'BUG: ElementModifier could not find the element it applies to'), definition.state, - capturedArgs + capturedArgs, + vm.env ); let instance: ModifierInstance = { @@ -280,7 +281,8 @@ APPEND_OPCODES.add(VM_DYNAMIC_MODIFIER_OP, (vm) => { owner, expect(constructing, 'BUG: ElementModifier could not find the element it applies to'), definition.state, - capturedArgs + capturedArgs, + vm.env ); return { diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/lists.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/lists.ts index 1dfae3891d1..37f408ff1be 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/lists.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/lists.ts @@ -23,7 +23,7 @@ APPEND_OPCODES.add(VM_ENTER_LIST_OP, (vm, { op1: relativeStart, op2: elseTarget let iteratorRef = createIteratorRef(listRef, key); let iterator = valueForRef(iteratorRef); - vm.updateWith(new AssertFilter(iteratorRef, (iterator) => iterator.isEmpty())); + vm.updateWith(new AssertFilter(iteratorRef, (iterator) => iterator.isEmpty(), vm.env.dom)); if (iterator.isEmpty()) { // TODO: Fix this offset, should be accurate diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts index a505a3ff5b6..710d9b530c4 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts @@ -1,4 +1,9 @@ -import type { CompilableTemplate, Nullable, UpdatingOpcode } from '@glimmer/interfaces'; +import type { + CompilableTemplate, + DOMRenderer, + Nullable, + UpdatingOpcode, +} from '@glimmer/interfaces'; import type { Reference } from '@glimmer/reference/lib/reference'; import type { Revision } from '@glimmer/validator/lib/validators'; import type { Tag } from '@glimmer/interfaces'; @@ -268,14 +273,15 @@ export class AssertFilter implements UpdatingOpcode { constructor( private ref: Reference, - private filter: (from: T) => U + private filter: (from: T, dom: DOMRenderer) => U, + dom: DOMRenderer ) { - this.last = filter(valueForRef(ref)); + this.last = filter(valueForRef(ref), dom); } evaluate(vm: UpdatingVM) { let { last, ref, filter } = this; - let current = filter(valueForRef(ref)); + let current = filter(valueForRef(ref), vm.dom); if (last !== current) { vm.throw(); diff --git a/packages/@glimmer/runtime/lib/dom/api.ts b/packages/@glimmer/runtime/lib/dom/api.ts deleted file mode 100644 index e05a9c93710..00000000000 --- a/packages/@glimmer/runtime/lib/dom/api.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { - AttrNamespace, - ElementNamespace, - GlimmerTreeConstruction, - Nullable, - SimpleElement, -} from '@glimmer/interfaces'; - -import { DOMOperations } from './operations'; - -export class TreeConstruction extends DOMOperations implements GlimmerTreeConstruction { - createElementNS(namespace: ElementNamespace, tag: string): SimpleElement { - return this.document.createElementNS(namespace, tag); - } - - setAttribute( - element: SimpleElement, - name: string, - value: string, - namespace: Nullable = null - ) { - if (namespace) { - element.setAttributeNS(namespace, name, value); - } else { - element.setAttribute(name, value); - } - } -} - -export const DOMTreeConstruction = TreeConstruction; -export type DOMTreeConstruction = TreeConstruction; diff --git a/packages/@glimmer/runtime/lib/dom/helper.ts b/packages/@glimmer/runtime/lib/dom/helper.ts index bed7fcf2814..4e45b8325f5 100644 --- a/packages/@glimmer/runtime/lib/dom/helper.ts +++ b/packages/@glimmer/runtime/lib/dom/helper.ts @@ -1,87 +1,6 @@ -import type { - GlimmerTreeChanges, - Nullable, - SimpleDocument, - SimpleElement, - SimpleNode, -} from '@glimmer/interfaces'; - -import { BLACKLIST_TABLE, DOMOperations } from './operations'; - -[ - 'b', - 'big', - 'blockquote', - 'body', - 'br', - 'center', - 'code', - 'dd', - 'div', - 'dl', - 'dt', - 'em', - 'embed', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'head', - 'hr', - 'i', - 'img', - 'li', - 'listing', - 'main', - 'meta', - 'nobr', - 'ol', - 'p', - 'pre', - 'ruby', - 's', - 'small', - 'span', - 'strong', - 'strike', - 'sub', - 'sup', - 'table', - 'tt', - 'u', - 'ul', - 'var', -].forEach((tag) => (BLACKLIST_TABLE[tag] = 1)); - const WHITESPACE = /[\t\n\v\f\r \xa0\u{1680}\u{180e}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}]/u; export function isWhitespace(string: string) { return WHITESPACE.test(string); } - -export class DOMChangesImpl extends DOMOperations implements GlimmerTreeChanges { - protected namespace: Nullable; - - constructor(protected override document: SimpleDocument) { - super(document); - this.namespace = null; - } - - setAttribute(element: SimpleElement, name: string, value: string) { - element.setAttribute(name, value); - } - - removeAttribute(element: SimpleElement, name: string) { - element.removeAttribute(name); - } - - insertAfter(element: SimpleElement, node: SimpleNode, reference: SimpleNode) { - this.insertBefore(element, node, reference.nextSibling); - } -} - -export const DOMChanges = DOMChangesImpl; -export { DOMTreeConstruction } from './api'; diff --git a/packages/@glimmer/runtime/lib/dom/normalize.ts b/packages/@glimmer/runtime/lib/dom/normalize.ts index d0c719942b5..ed2f758574d 100644 --- a/packages/@glimmer/runtime/lib/dom/normalize.ts +++ b/packages/@glimmer/runtime/lib/dom/normalize.ts @@ -1,4 +1,4 @@ -import type { Dict, Indexable, SimpleDocumentFragment, SimpleNode } from '@glimmer/interfaces'; +import type { Dict, Indexable, SimpleNode } from '@glimmer/interfaces'; export interface SafeString { toHTML(): string; @@ -15,22 +15,6 @@ export function normalizeStringValue(value: unknown): string { return String(value); } -export function normalizeTrustedValue(value: unknown): TrustingInsertion { - if (isEmpty(value)) { - return ''; - } - if (isString(value)) { - return value; - } - if (isSafeString(value)) { - return value.toHTML(); - } - if (isNode(value)) { - return value; - } - return String(value); -} - export function shouldCoerce( value: unknown ): value is string | number | boolean | null | undefined { @@ -51,14 +35,6 @@ export function isSafeString(value: unknown): value is SafeString { return isIndexable(value) && typeof value['toHTML'] === 'function'; } -export function isNode(value: unknown): value is SimpleNode { - return isIndexable(value) && typeof value['nodeType'] === 'number'; -} - -export function isFragment(value: unknown): value is SimpleDocumentFragment { - return isIndexable(value) && value['nodeType'] === 11; -} - export function isString(value: unknown): value is string { return typeof value === 'string'; } diff --git a/packages/@glimmer/runtime/lib/dom/operations.ts b/packages/@glimmer/runtime/lib/dom/operations.ts deleted file mode 100644 index 5cbcc133921..00000000000 --- a/packages/@glimmer/runtime/lib/dom/operations.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - Bounds, - Dict, - Namespace, - Nullable, - SimpleComment, - SimpleDocument, - SimpleElement, - SimpleNode, - SimpleText, -} from '@glimmer/interfaces'; -import { - INSERT_BEFORE_BEGIN, - INSERT_BEFORE_END, - NS_MATHML, - NS_SVG, -} from '@glimmer/constants/lib/dom'; -import { expect } from '@glimmer/debug-util/lib/platform-utils'; - -import { ConcreteBounds } from '../bounds'; - -// http://www.w3.org/TR/html/syntax.html#html-integration-point -const SVG_INTEGRATION_POINTS = { foreignObject: 1, desc: 1, title: 1 }; - -// http://www.w3.org/TR/html/syntax.html#adjust-svg-attributes -// TODO: Adjust SVG attributes - -// http://www.w3.org/TR/html/syntax.html#parsing-main-inforeign -// TODO: Adjust SVG elements - -// http://www.w3.org/TR/html/syntax.html#parsing-main-inforeign -export const BLACKLIST_TABLE = Object.create(null) as Dict<1>; - -export class DOMOperations { - declare protected uselessElement: SimpleElement; // Set by this.setupUselessElement() in constructor - - constructor(protected document: SimpleDocument) { - this.setupUselessElement(); - } - - // split into separate method so that NodeDOMTreeConstruction - // can override it. - protected setupUselessElement() { - this.uselessElement = this.document.createElement('div'); - } - - createElement(tag: string, context?: SimpleElement): SimpleElement { - let isElementInSVGNamespace: boolean, - isHTMLIntegrationPoint: boolean, - isElementInMathMlNamespace: boolean, - ns: Namespace; - - if (context) { - isElementInSVGNamespace = context.namespaceURI === NS_SVG || tag === 'svg'; - isElementInMathMlNamespace = context.namespaceURI === NS_MATHML || tag === 'math'; - isHTMLIntegrationPoint = !!(SVG_INTEGRATION_POINTS as Dict)[context.tagName]; - } else { - isElementInSVGNamespace = tag === 'svg'; - isElementInMathMlNamespace = tag === 'math'; - isHTMLIntegrationPoint = false; - } - - if ((isElementInMathMlNamespace || isElementInSVGNamespace) && !isHTMLIntegrationPoint) { - // FIXME: This does not properly handle with color, face, or - // size attributes, which is also disallowed by the spec. We should fix - // this. - if (BLACKLIST_TABLE[tag]) { - throw new Error(`Cannot create a ${tag} inside an SVG context`); - } - if (isElementInMathMlNamespace) { - ns = NS_MATHML; - } else { - ns = NS_SVG; - } - - return this.document.createElementNS(ns, tag); - } else { - return this.document.createElement(tag); - } - } - - insertBefore(parent: SimpleElement, node: SimpleNode, reference: Nullable) { - parent.insertBefore(node, reference); - } - - insertHTMLBefore(parent: SimpleElement, nextSibling: Nullable, html: string): Bounds { - if (html === '') { - const comment = this.createComment(''); - parent.insertBefore(comment, nextSibling); - return new ConcreteBounds(parent, comment, comment); - } - - const prev = nextSibling ? nextSibling.previousSibling : parent.lastChild; - let last: SimpleNode; - - if (nextSibling === null) { - parent.insertAdjacentHTML(INSERT_BEFORE_END, html); - last = expect(parent.lastChild, 'bug in insertAdjacentHTML?'); - } else if (nextSibling instanceof HTMLElement) { - nextSibling.insertAdjacentHTML('beforebegin', html); - last = expect(nextSibling.previousSibling, 'bug in insertAdjacentHTML?'); - } else { - // Non-element nodes do not support insertAdjacentHTML, so add an - // element and call it on that element. Then remove the element. - const { uselessElement } = this; - - parent.insertBefore(uselessElement, nextSibling); - uselessElement.insertAdjacentHTML(INSERT_BEFORE_BEGIN, html); - last = expect(uselessElement.previousSibling, 'bug in insertAdjacentHTML?'); - parent.removeChild(uselessElement); - } - - const first = expect(prev ? prev.nextSibling : parent.firstChild, 'bug in insertAdjacentHTML?'); - return new ConcreteBounds(parent, first, last); - } - - createTextNode(text: string): SimpleText { - return this.document.createTextNode(text); - } - - createComment(data: string): SimpleComment { - return this.document.createComment(data); - } -} - -export function moveNodesBefore( - source: SimpleNode, - target: SimpleElement, - nextSibling: Nullable -): Bounds { - const first = expect(source.firstChild, 'source is empty'); - let last: SimpleNode = first; - let current: Nullable = first; - - while (current) { - const next: Nullable = current.nextSibling; - - target.insertBefore(current, nextSibling); - - last = current; - current = next; - } - - return new ConcreteBounds(target, first, last); -} diff --git a/packages/@glimmer/runtime/lib/dom/props.ts b/packages/@glimmer/runtime/lib/dom/props.ts index dd82cf56137..5a8732b0a23 100644 --- a/packages/@glimmer/runtime/lib/dom/props.ts +++ b/packages/@glimmer/runtime/lib/dom/props.ts @@ -1,4 +1,4 @@ -import type { Dict, SimpleElement } from '@glimmer/interfaces'; +import type { Dict, DOMRenderer, SimpleElement } from '@glimmer/interfaces'; /* * @method normalizeProperty @@ -6,15 +6,15 @@ import type { Dict, SimpleElement } from '@glimmer/interfaces'; * @param slotName {String} * @returns {Object} { name, type } */ -export function normalizeProperty(element: SimpleElement, slotName: string) { +export function normalizeProperty(dom: DOMRenderer, element: SimpleElement, slotName: string) { let type, normalized; - if (slotName in element) { + if (dom.hasProperty(element, slotName)) { normalized = slotName; type = 'prop'; } else { let lower = slotName.toLowerCase(); - if (lower in element) { + if (dom.hasProperty(element, lower)) { type = 'prop'; normalized = lower; } else { @@ -25,7 +25,7 @@ export function normalizeProperty(element: SimpleElement, slotName: string) { if ( type === 'prop' && - (normalized.toLowerCase() === 'style' || preferAttr(element.tagName, normalized)) + (normalized.toLowerCase() === 'style' || preferAttr(dom.tagName(element), normalized)) ) { type = 'attr'; } diff --git a/packages/@glimmer/runtime/lib/dom/renderer.ts b/packages/@glimmer/runtime/lib/dom/renderer.ts new file mode 100644 index 00000000000..70c6dd1c2de --- /dev/null +++ b/packages/@glimmer/runtime/lib/dom/renderer.ts @@ -0,0 +1,337 @@ +import type { + AttrNamespace, + Bounds, + Dict, + DOMRenderer, + ElementNamespace, + Indexable, + Namespace, + Nullable, + SimpleComment, + SimpleDocument, + SimpleDocumentFragment, + SimpleElement, + SimpleNode, + SimpleText, +} from '@glimmer/interfaces'; +import { + COMMENT_NODE, + DOCUMENT_FRAGMENT_NODE, + ELEMENT_NODE, + INSERT_BEFORE_BEGIN, + INSERT_BEFORE_END, + NS_MATHML, + NS_SVG, + TEXT_NODE, +} from '@glimmer/constants/lib/dom'; +import { expect } from '@glimmer/debug-util/lib/platform-utils'; + +import { ConcreteBounds } from '../bounds'; + +// http://www.w3.org/TR/html/syntax.html#html-integration-point +const SVG_INTEGRATION_POINTS = { foreignObject: 1, desc: 1, title: 1 }; + +// http://www.w3.org/TR/html/syntax.html#adjust-svg-attributes +// TODO: Adjust SVG attributes + +// http://www.w3.org/TR/html/syntax.html#parsing-main-inforeign +// TODO: Adjust SVG elements + +// http://www.w3.org/TR/html/syntax.html#parsing-main-inforeign +const BLACKLIST_TABLE = Object.create(null) as Dict<1>; + +[ + 'b', + 'big', + 'blockquote', + 'body', + 'br', + 'center', + 'code', + 'dd', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'hr', + 'i', + 'img', + 'li', + 'listing', + 'main', + 'meta', + 'nobr', + 'ol', + 'p', + 'pre', + 'ruby', + 's', + 'small', + 'span', + 'strong', + 'strike', + 'sub', + 'sup', + 'table', + 'tt', + 'u', + 'ul', + 'var', +].forEach((tag) => (BLACKLIST_TABLE[tag] = 1)); + +/** + * The `DOMRenderer` for a browser `Document`, and for a SimpleDOM document. + * + * Do not read or write a member of a DOM node in other runtime code. + * Add a method here and to `DOMRenderer`. + */ +export class DOMRendererImpl implements DOMRenderer { + declare protected uselessElement: SimpleElement; // Set by this.setupUselessElement() in constructor + + constructor(protected document: SimpleDocument) { + this.setupUselessElement(); + } + + // split into separate method so that NodeDOMRenderer + // can override it. + protected setupUselessElement() { + this.uselessElement = this.document.createElement('div'); + } + + createElement(tag: string, context?: SimpleElement): SimpleElement { + let isElementInSVGNamespace: boolean, + isHTMLIntegrationPoint: boolean, + isElementInMathMlNamespace: boolean, + ns: Namespace; + + if (context) { + isElementInSVGNamespace = context.namespaceURI === NS_SVG || tag === 'svg'; + isElementInMathMlNamespace = context.namespaceURI === NS_MATHML || tag === 'math'; + isHTMLIntegrationPoint = !!(SVG_INTEGRATION_POINTS as Dict)[context.tagName]; + } else { + isElementInSVGNamespace = tag === 'svg'; + isElementInMathMlNamespace = tag === 'math'; + isHTMLIntegrationPoint = false; + } + + if ((isElementInMathMlNamespace || isElementInSVGNamespace) && !isHTMLIntegrationPoint) { + // FIXME: This does not properly handle with color, face, or + // size attributes, which is also disallowed by the spec. We should fix + // this. + if (BLACKLIST_TABLE[tag]) { + throw new Error(`Cannot create a ${tag} inside an SVG context`); + } + if (isElementInMathMlNamespace) { + ns = NS_MATHML; + } else { + ns = NS_SVG; + } + + return this.document.createElementNS(ns, tag); + } else { + return this.document.createElement(tag); + } + } + + createElementNS(namespace: ElementNamespace, tag: string): SimpleElement { + return this.document.createElementNS(namespace, tag); + } + + createTextNode(text: string): SimpleText { + return this.document.createTextNode(text); + } + + createComment(data: string): SimpleComment { + return this.document.createComment(data); + } + + insertBefore(parent: SimpleElement, node: SimpleNode, reference: Nullable) { + parent.insertBefore(node, reference); + } + + insertHTMLBefore(parent: SimpleElement, nextSibling: Nullable, html: string): Bounds { + if (html === '') { + const comment = this.createComment(''); + parent.insertBefore(comment, nextSibling); + return new ConcreteBounds(parent, comment, comment); + } + + const prev = nextSibling ? nextSibling.previousSibling : parent.lastChild; + let last: SimpleNode; + + if (nextSibling === null) { + parent.insertAdjacentHTML(INSERT_BEFORE_END, html); + last = expect(parent.lastChild, 'bug in insertAdjacentHTML?'); + } else if (nextSibling instanceof HTMLElement) { + nextSibling.insertAdjacentHTML('beforebegin', html); + last = expect(nextSibling.previousSibling, 'bug in insertAdjacentHTML?'); + } else { + // Non-element nodes do not support insertAdjacentHTML, so add an + // element and call it on that element. Then remove the element. + const { uselessElement } = this; + + parent.insertBefore(uselessElement, nextSibling); + uselessElement.insertAdjacentHTML(INSERT_BEFORE_BEGIN, html); + last = expect(uselessElement.previousSibling, 'bug in insertAdjacentHTML?'); + parent.removeChild(uselessElement); + } + + const first = expect(prev ? prev.nextSibling : parent.firstChild, 'bug in insertAdjacentHTML?'); + return new ConcreteBounds(parent, first, last); + } + + removeChild(parent: SimpleElement, node: SimpleNode): void { + parent.removeChild(node); + } + + clearChildren(element: SimpleElement): void { + while (element.lastChild) { + element.removeChild(element.lastChild); + } + } + + parentNode(node: SimpleNode): Nullable { + return node.parentNode; + } + + firstChild(node: SimpleNode): Nullable { + return node.firstChild; + } + + lastChild(node: SimpleNode): Nullable { + return node.lastChild; + } + + nextSibling(node: SimpleNode): Nullable { + return node.nextSibling; + } + + previousSibling(node: SimpleNode): Nullable { + return node.previousSibling; + } + + querySelector(element: SimpleElement, selector: string): Nullable { + return (element as unknown as Element).querySelector( + selector + ) as unknown as Nullable; + } + + isNode(value: unknown): value is SimpleNode { + return ( + value !== null && + typeof value === 'object' && + typeof (value as Indexable)['nodeType'] === 'number' + ); + } + + isFragment(value: unknown): value is SimpleDocumentFragment { + return ( + value !== null && + typeof value === 'object' && + (value as Indexable)['nodeType'] === DOCUMENT_FRAGMENT_NODE + ); + } + + isElement(node: SimpleNode): node is SimpleElement { + return node.nodeType === ELEMENT_NODE; + } + + isText(node: SimpleNode): node is SimpleText { + return node.nodeType === TEXT_NODE; + } + + isComment(node: SimpleNode): node is SimpleComment { + return node.nodeType === COMMENT_NODE; + } + + tagName(element: SimpleElement): string { + return element.tagName; + } + + namespaceURI(element: SimpleElement): Nullable { + return element.namespaceURI; + } + + nodeValue(node: SimpleNode): Nullable { + return node.nodeValue; + } + + setNodeValue(node: SimpleNode, value: string): void { + (node as SimpleText | SimpleComment).nodeValue = value; + } + + attributeNames(element: SimpleElement): string[] { + let { attributes } = element; + let names: string[] = []; + + for (let i = 0; i < attributes.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + names.push(attributes[i]!.name); + } + + return names; + } + + getAttribute(element: SimpleElement, name: string): Nullable { + return element.getAttribute(name); + } + + setAttribute( + element: SimpleElement, + name: string, + value: string, + namespace: Nullable = null + ) { + if (namespace) { + element.setAttributeNS(namespace, name, value); + } else { + element.setAttribute(name, value); + } + } + + removeAttribute(element: SimpleElement, name: string, namespace: Nullable = null) { + if (namespace) { + element.removeAttributeNS(namespace, name); + } else { + element.removeAttribute(name); + } + } + + hasProperty(element: SimpleElement, name: string): boolean { + return name in element; + } + + getProperty(element: SimpleElement, name: string): unknown { + return (element as unknown as Dict)[name]; + } + + setProperty(element: SimpleElement, name: string, value: unknown): void { + (element as unknown as Dict)[name] = value; + } + + addEventListener( + element: SimpleElement, + eventName: string, + callback: EventListener, + options?: AddEventListenerOptions + ): void { + (element as unknown as Element).addEventListener(eventName, callback, options); + } + + removeEventListener( + element: SimpleElement, + eventName: string, + callback: EventListener, + options?: AddEventListenerOptions + ): void { + (element as unknown as Element).removeEventListener(eventName, callback, options); + } +} diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index 9a15147e3b1..a277c013148 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -1,4 +1,4 @@ -import type { Nullable, SimpleElement } from '@glimmer/interfaces'; +import type { Nullable } from '@glimmer/interfaces'; import { isSafeString, normalizeStringValue } from '../dom/normalize'; @@ -123,7 +123,7 @@ function protocolForUrl(url: string): string { } export function sanitizeAttributeValue( - element: SimpleElement, + tagName: string, attribute: string, value: unknown ): unknown { @@ -135,8 +135,6 @@ export function sanitizeAttributeValue( return value.toHTML(); } - const tagName = element.tagName; - let str = normalizeStringValue(value); if (checkURI(tagName, attribute)) { diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index bafbefe2f82..1085838063f 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -4,8 +4,7 @@ import type { ComponentInstanceWithCreate, Environment, EnvironmentOptions, - GlimmerTreeChanges, - GlimmerTreeConstruction, + DOMRenderer, ModifierInstance, Nullable, RuntimeArtifacts, @@ -20,7 +19,7 @@ import { track } from '@glimmer/validator/lib/tracking'; import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; import DebugRenderTree from './debug-render-tree'; -import { DOMChangesImpl, DOMTreeConstruction } from './dom/helper'; +import { DOMRendererImpl } from './dom/renderer'; import { isArgumentError } from './vm/arguments'; export const TRANSACTION: TransactionSymbol = Symbol('TRANSACTION') as TransactionSymbol; @@ -99,8 +98,7 @@ class TransactionImpl implements Transaction { export class EnvironmentImpl implements Environment { [TRANSACTION]: Nullable = null; - declare protected appendOperations: GlimmerTreeConstruction; - protected updateOperations?: GlimmerTreeChanges | undefined; + declare readonly dom: DOMRenderer; // Delegate methods and values public isInteractive: boolean; @@ -116,28 +114,15 @@ export class EnvironmentImpl implements Environment { this.isInteractive = delegate.isInteractive; this.debugRenderTree = this.delegate.enableDebugTooling ? new DebugRenderTree() : undefined; this.isArgumentCaptureError = this.delegate.enableDebugTooling ? isArgumentError : undefined; - if (options.appendOperations) { - this.appendOperations = options.appendOperations; - this.updateOperations = options.updateOperations; + if (options.dom) { + this.dom = options.dom; } else if (options.document) { - this.appendOperations = new DOMTreeConstruction(options.document); - this.updateOperations = new DOMChangesImpl(options.document); + this.dom = new DOMRendererImpl(options.document); } else if (DEBUG) { - throw new Error('you must pass document or appendOperations to a new runtime'); + throw new Error('you must pass document or dom to a new runtime'); } } - getAppendOperations(): GlimmerTreeConstruction { - return this.appendOperations; - } - - getDOM(): GlimmerTreeChanges { - return expect( - this.updateOperations, - 'Attempted to get DOM updateOperations, but they were not provided by the environment. You may be attempting to rerender in an environment which does not support rerendering, such as SSR.' - ); - } - begin() { assert( !this[TRANSACTION], diff --git a/packages/@glimmer/runtime/lib/modifiers/on.ts b/packages/@glimmer/runtime/lib/modifiers/on.ts index e8c38554895..d48b3fa2984 100644 --- a/packages/@glimmer/runtime/lib/modifiers/on.ts +++ b/packages/@glimmer/runtime/lib/modifiers/on.ts @@ -1,6 +1,8 @@ import { DEBUG } from '@glimmer/env'; import type { CapturedArguments, + DOMRenderer, + Environment, InternalModifierManager, Owner, SimpleElement, @@ -36,36 +38,43 @@ interface Listener { export class OnModifierState { public tag = createUpdatableTag(); - public element: Element; + public element: SimpleElement; public args: CapturedArguments; public listener: Listener | null = null; + private dom: DOMRenderer; - constructor(element: Element, args: CapturedArguments) { + constructor(dom: DOMRenderer, element: SimpleElement, args: CapturedArguments) { + this.dom = dom; this.element = element; this.args = args; registerDestructor(this, () => { - let { element, listener } = this; + let { dom, element, listener } = this; if (listener) { let { eventName, callback, options } = listener; - removeEventListener(element, eventName, callback, options); + removeEventListener(dom, element, eventName, callback, options); } }); } // Update this.listener if needed updateListener(): void { - let { element, args, listener } = this; + let { dom, element, args, listener } = this; let selector: string | undefined; if (DEBUG) { - const el = this.element; + let id = dom.getAttribute(element, 'id'); + let classes = dom.getAttribute(element, 'class')?.trim(); + selector = - el.tagName.toLowerCase() + - (el.id ? `#${el.id}` : '') + - Array.from(el.classList) - .map((c) => `.${c}`) - .join(''); + dom.tagName(element).toLowerCase() + + (id ? `#${id}` : '') + + (classes + ? classes + .split(/\s+/u) + .map((c) => `.${c}`) + .join('') + : ''); } let arg0 = args.positional[0]; @@ -214,10 +223,10 @@ export class OnModifierState { }; if (listener) { - removeEventListener(element, listener.eventName, listener.callback, listener.options); + removeEventListener(dom, element, listener.eventName, listener.callback, listener.options); } - addEventListener(element, eventName, callback, options); + addEventListener(dom, element, eventName, callback, options); } } } @@ -226,24 +235,26 @@ let adds = 0; let removes = 0; function removeEventListener( - element: Element, + dom: DOMRenderer, + element: SimpleElement, eventName: string, callback: EventListener, options?: AddEventListenerOptions ): void { removes++; - element.removeEventListener(eventName, callback, options); + dom.removeEventListener(element, eventName, callback, options); } function addEventListener( - element: Element, + dom: DOMRenderer, + element: SimpleElement, eventName: string, callback: EventListener, options?: AddEventListenerOptions ): void { adds++; - element.addEventListener(eventName, callback, options); + dom.addEventListener(element, eventName, callback, options); } /** @@ -401,11 +412,12 @@ class OnModifierManager implements InternalModifierManager { create( _owner: Owner, - element: SimpleElement | Element, + element: SimpleElement, _state: object, - args: CapturedArguments + args: CapturedArguments, + env: Environment ): OnModifierState { - return new OnModifierState(element as Element, args); + return new OnModifierState(env.dom, element, args); } getTag({ tag }: OnModifierState): UpdatableTag { diff --git a/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts b/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts index 8625d050450..b6dd5718705 100644 --- a/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts +++ b/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts @@ -4,39 +4,38 @@ import type { AttributeOperation, AttrNamespace, Dict, + DOMRenderer, Environment, Nullable, SimpleElement, TreeBuilder, } from '@glimmer/interfaces'; import { NS_SVG } from '@glimmer/constants/lib/dom'; -import { castToBrowser } from '@glimmer/debug-util/lib/simple-cast'; import { warnIfStyleNotTrusted } from '@glimmer/global-context'; -import type { MutableKey } from '../element-builder'; - import { normalizeStringValue } from '../../dom/normalize'; import { normalizeProperty } from '../../dom/props'; import { requiresSanitization, sanitizeAttributeValue } from '../../dom/sanitized-values'; export function dynamicAttribute( + dom: DOMRenderer, element: SimpleElement, attr: string, namespace: Nullable, isTrusting = false ): DynamicAttribute { - const { tagName, namespaceURI } = element; - const attribute = { element, name: attr, namespace }; + const tagName = dom.tagName(element); + const attribute = { element, name: attr, namespace, tagName }; if (DEBUG && attr === 'style' && !isTrusting) { return new DebugStyleAttributeManager(attribute); } - if (namespaceURI === NS_SVG) { + if (dom.namespaceURI(element) === NS_SVG) { return buildDynamicAttribute(tagName, attr, attribute); } - const { type, normalized } = normalizeProperty(element, attr); + const { type, normalized } = normalizeProperty(dom, element, attr); if (type === 'attr') { return buildDynamicAttribute(tagName, normalized, attribute); @@ -94,14 +93,14 @@ export class SimpleDynamicAttribute extends DynamicAttribute { } } - update(value: unknown, _env: Environment): void { + update(value: unknown, env: Environment): void { const normalizedValue = normalizeValue(value); const { element, name } = this.attribute; if (normalizedValue === null) { - element.removeAttribute(name); + env.dom.removeAttribute(element, name); } else { - element.setAttribute(name, normalizedValue); + env.dom.setAttribute(element, name, normalizedValue); } } } @@ -122,56 +121,51 @@ export class DefaultDynamicProperty extends DynamicAttribute { } } - update(value: unknown, _env: Environment): void { + update(value: unknown, env: Environment): void { const { element } = this.attribute; if (this.value !== value) { - (element as unknown as Element)[this.normalizedName as MutableKey] = this.value = - value as never; + env.dom.setProperty(element, this.normalizedName, (this.value = value)); if (value === null || value === undefined) { - this.removeAttribute(); + this.removeAttribute(env.dom); } } } - protected removeAttribute() { + protected removeAttribute(dom: DOMRenderer) { // TODO this sucks but to preserve properties first and to meet current // semantics we must do this. const { element, namespace } = this.attribute; - if (namespace) { - element.removeAttributeNS(namespace, this.normalizedName); - } else { - element.removeAttribute(this.normalizedName); - } + dom.removeAttribute(element, this.normalizedName, namespace); } } export class SafeDynamicProperty extends DefaultDynamicProperty { override set(dom: TreeBuilder, value: unknown, env: Environment): void { - const { element, name } = this.attribute; - const sanitized = sanitizeAttributeValue(element, name, value); + const { tagName, name } = this.attribute; + const sanitized = sanitizeAttributeValue(tagName, name, value); super.set(dom, sanitized, env); } override update(value: unknown, env: Environment): void { - const { element, name } = this.attribute; - const sanitized = sanitizeAttributeValue(element, name, value); + const { tagName, name } = this.attribute; + const sanitized = sanitizeAttributeValue(tagName, name, value); super.update(sanitized, env); } } export class SafeDynamicAttribute extends SimpleDynamicAttribute { override set(dom: TreeBuilder, value: unknown, env: Environment): void { - const { element, name } = this.attribute; - const sanitized = sanitizeAttributeValue(element, name, value); + const { tagName, name } = this.attribute; + const sanitized = sanitizeAttributeValue(tagName, name, value); super.set(dom, sanitized, env); } override update(value: unknown, env: Environment): void { - const { element, name } = this.attribute; - const sanitized = sanitizeAttributeValue(element, name, value); + const { tagName, name } = this.attribute; + const sanitized = sanitizeAttributeValue(tagName, name, value); super.update(sanitized, env); } } @@ -184,17 +178,17 @@ export class InputValueDynamicAttribute extends DefaultDynamicProperty { // GH#19219: Browsers don't reflect `input.value = ''` as a value attribute when // type is later changed to "radio"/"checkbox". Explicitly set the attribute for . // Not needed for