Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
97d2ac4
shadowRoot) allow bind at template
phillipc Dec 21, 2025
9b82c26
Merge remote-tracking branch 'tko_base/main' into shadowRoot
phillipc Dec 24, 2025
652d3f0
shadowDom) some cleanups + new test-cases
phillipc Dec 26, 2025
e5a0292
add a comment
phillipc Dec 26, 2025
a09f181
shadowRoot) cleanup and test 'domNodeIsContainedBy'
phillipc Dec 26, 2025
85bede9
shadowRoot)
phillipc Dec 26, 2025
eef76f0
fix) remove var
phillipc Dec 26, 2025
e2d3348
Update applyBindings.ts
phillipc Dec 26, 2025
2ad076e
fix) type and format
phillipc Dec 26, 2025
4ef2146
disposing) Document and DocumentFragment was not taken into account
phillipc Dec 26, 2025
1d15907
disposing) use querySelectorAll
phillipc Dec 26, 2025
c8bfb66
test) direct applyBinding to document fragment
phillipc Dec 26, 2025
dccb6de
Merge branch 'knockout:main' into shadowRoot
phillipc Dec 29, 2025
8136e63
Merge branch 'main' into shadowRoot
phillipc Apr 3, 2026
7f4f7ec
Merge branch 'main' into shadowRoot
phillipc Apr 5, 2026
eb4eea4
Merge branch 'main' into shadowRoot
phillipc Apr 9, 2026
9f4a5cd
Merge branch 'main' into shadowRoot
phillipc Apr 11, 2026
e8b6d83
Merge remote-tracking branch 'tko_base/main' into shadowRoot
phillipc Apr 26, 2026
5634883
shadowRoot) address remaining PR #229 review findings
phillipc Apr 26, 2026
b2ea092
shadowRoot) add tests for template.content disposal and shadow DOM bi…
phillipc Apr 26, 2026
6b96683
shadowRoot) address local review: dedupe test, re-enable build.knocko…
phillipc Apr 26, 2026
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
2 changes: 1 addition & 1 deletion builds/knockout/spec/bindingAttributeBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ describe('Binding attribute syntax', function() {
expect(testNode).toContainHtml('<p>replaced</p><textarea>test</textarea><p>replaced</p>');
});

it('<template>', function() {
xit('<template>', function() { //Disabled because TKO allows binding in <template> elements
document.createElement('template'); // For old IE
testNode.innerHTML = "<p>Hello</p><template>test</template><p>Goodbye</p>";
ko.applyBindings({ sometext: 'hello' }, testNode);
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"type": "git",
"url": "https://github.com/knockout/tko.git"
},
"scripts": {},
"scripts": {
"test": "make sweep && make && make test-headless"
},
"bugs": "https://github.com/knockout/tko/issues",
"licenses": [
{
Expand Down
3 changes: 1 addition & 2 deletions packages/bind/spec/bindingAttributeBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -876,10 +876,9 @@ describe('Binding attribute syntax', function () {
})

it('<template>', function () {
document.createElement('template') // For old IE
testNode.innerHTML = '<p>Hello</p><template>test</template><p>Goodbye</p>'
applyBindings({ sometext: 'hello' }, testNode)
expect(testNode).toContainHtml('<p>replaced</p><template>test</template><p>replaced</p>')
expect(testNode).toContainHtml('<p>replaced</p><template>replaced</template><p>replaced</p>')
})
})

Expand Down
24 changes: 16 additions & 8 deletions packages/bind/src/applyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,20 @@ type BindingHandlerOrUndefined = (typeof BindingHandler & BindingHandler) | unde
const bindingDoesNotRecurseIntoElementTypes = {
// Don't want bindings that operate on text nodes to mutate <script> and <textarea> contents,
// because it's unexpected and a potential XSS issue.
// Also bindings should not operate on <template> elements since this breaks in Internet Explorer
// and because such elements' contents are always intended to be bound in a different context
// from where they appear in the document.
script: true,
textarea: true,
template: true
textarea: true
}

function getBindingProvider(): Provider {
return options.bindingProviderInstance.instance || options.bindingProviderInstance
}

function isProviderForNode(provider: Provider, node: Node): boolean {
const nodeTypes = provider.FOR_NODE_TYPES || [Node.ELEMENT_NODE, Node.TEXT_NODE, Node.COMMENT_NODE]
const nodeTypes = provider.FOR_NODE_TYPES || [
Node.ELEMENT_NODE,
Node.TEXT_NODE,
Node.COMMENT_NODE
]
return nodeTypes.includes(node.nodeType)
}

Expand Down Expand Up @@ -473,7 +473,11 @@ export function applyBindingsToDescendants<T = any>(
): BindingResult {
const asyncBindingsApplied = new Set()
const bindingContext = getBindingContext(viewModelOrBindingContext)
if (rootNode.nodeType === Node.ELEMENT_NODE || rootNode.nodeType === Node.COMMENT_NODE) {
if (
rootNode.nodeType === Node.ELEMENT_NODE
|| rootNode.nodeType === Node.COMMENT_NODE
|| rootNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
) {
applyBindingsToDescendantsInternal(bindingContext, rootNode, asyncBindingsApplied)
return new BindingResult({ asyncBindingsApplied, rootNode, bindingContext })
}
Expand All @@ -493,7 +497,11 @@ export function applyBindings<T = any>(
if (!rootNode) {
throw Error('ko.applyBindings: could not find window.document.body; has the document been loaded?')
}
} else if (rootNode.nodeType !== Node.ELEMENT_NODE && rootNode.nodeType !== Node.COMMENT_NODE) {
} else if (
rootNode.nodeType !== Node.ELEMENT_NODE
&& rootNode.nodeType !== Node.COMMENT_NODE
&& rootNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw Error('ko.applyBindings: first parameter should be your view model; second parameter should be a DOM node')
}
const rootContext = getBindingContext<T>(viewModelOrBindingContext, extendContextCallback)
Expand Down
33 changes: 33 additions & 0 deletions packages/binding.component/spec/componentBindingBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,39 @@ describe('Components: Component binding', function () {
expect(innerText).toEqual(`X beep Y Gamma Zeta Q`)
})

it('processes default and named slots in template', function () {
const fragment = document.createDocumentFragment()
const template = document.createElement('template') as HTMLTemplateElement
fragment.appendChild(template)

template.innerHTML = `
<test-component>
<template slot='alpha'>beep</template>
Gamma
<div>Zeta</div>
</test-component>
`
class ViewModel extends components.ComponentABC {
static override get template() {
return `
<div>
X <slot name='alpha'></slot> Y <slot></slot> Q
</div>
`
}
}
ViewModel.register('test-component')

const usedCopie = template.cloneNode(true) as HTMLTemplateElement
applyBindings(outerViewModel, usedCopie)

const innerText = (usedCopie.content.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()
expect(innerText).toEqual(`X beep Y Gamma Zeta Q`)

const innerTextOrg = (template.content.children[0] as HTMLElement).innerText.replace(/\s+/g, ' ').trim()
expect(innerTextOrg).toEqual('Gamma Zeta')
})

it('inserts all component template nodes in an unnamed (default) slot', function () {
testNode.innerHTML = `
<test-component>
Expand Down
2 changes: 1 addition & 1 deletion packages/binding.foreach/src/foreach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ export class ForEachBinding extends AsyncBindingHandler {
*/
activeChildElement(node) {
const active = document.activeElement
if (domNodeIsContainedBy(active!, node)) {
if (domNodeIsContainedBy(active, node)) {
return active
}
return null
Expand Down
10 changes: 10 additions & 0 deletions packages/binding.template/spec/foreachBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ describe('Binding: Foreach', function () {
)
})

it('Should be able to use $data to reference each array item being bound in HTMLTemplate with HTMLSlotElement', function () {
testNode.innerHTML =
"<template><div data-bind='foreach: someItems'><slot data-bind='text: $data'></slot></div></template>"
const someItems = ['alpha', 'beta']
applyBindings({ someItems: someItems }, testNode)
expect((testNode.childNodes[0] as HTMLTemplateElement).content.firstChild).toContainHtml(
'<slot data-bind="text: $data">alpha</slot><slot data-bind="text: $data">beta</slot>'
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('Should add and remove nodes to match changes in the bound array', function () {
testNode.innerHTML = "<div data-bind='foreach: someItems'><span data-bind='text: childProp'></span></div>"
const someItems = observableArray([{ childProp: 'first child' }, { childProp: 'second child' }])
Expand Down
25 changes: 25 additions & 0 deletions packages/binding.template/spec/nativeTemplateEngineBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,31 @@ describe('Native template engine', function () {
})
})

describe('Template data context', function () {
it('should set $data to the template data value', function () {
const fragment = document.createDocumentFragment()
const template = document.createElement('template') as HTMLTemplateElement
fragment.appendChild(template)

template.innerHTML =
"<div data-bind='template: { data: someItem }'>" + "Value: <span data-bind='text: $data.val'></span>" + '</div>'
applyBindings({ someItem: { val: 'abc' } }, template)
expect(template.content.childNodes[0]).toContainText('Value: abc')
})

it('should set $data to the DIV at DocumentFragment', function () {
const fragment = document.createDocumentFragment()
const div = document.createElement('div') as HTMLDivElement

div.innerHTML =
"<div data-bind='template: { data: someItem }'>" + "Value: <span data-bind='text: $data.val'></span>" + '</div>'

fragment.appendChild(div)
applyBindings({ someItem: { val: 'abc' } }, div)
expect(div.childNodes[0]).toContainText('Value: abc')
Comment thread
phillipc marked this conversation as resolved.
Outdated
})
})

describe('Data-bind syntax', function () {
it('should expose parent binding context as $parent if binding with an explicit \"data\" value', function () {
testNode.innerHTML =
Expand Down
61 changes: 61 additions & 0 deletions packages/utils/spec/utilsDomBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as utils from '../dist'
import { registerEventHandler, virtualElements } from '../dist'
import options from '../dist/options'
import type { KnockoutInstance } from '@tko/builder'
import { domNodeIsContainedBy } from '../src'

Comment thread
phillipc marked this conversation as resolved.
Outdated
const ko: KnockoutInstance = globalThis.ko || {}
ko.utils = utils
Expand All @@ -16,6 +17,66 @@ describe('startCommentRegex', function () {
})
})

describe('DOM-Info Tool', function () {
it('domNodeIsContainedBy with special values', function () {
const parent = document.createElement('div')
const test = document.createDocumentFragment()

expect(domNodeIsContainedBy(parent, test)).toBe(false)
expect(domNodeIsContainedBy(test, parent)).toBe(false)

expect(domNodeIsContainedBy(parent, undefined)).toBe(false)
expect(domNodeIsContainedBy(parent, null)).toBe(false)
expect(domNodeIsContainedBy(null, parent)).toBe(false)

test.appendChild(parent)

expect(domNodeIsContainedBy(parent, test)).toBe(true)
expect(domNodeIsContainedBy(test, parent)).toBe(false)

const testDiv = document.createElement('div')
const template = document.createElement('template')
template.content.appendChild(testDiv)
expect(domNodeIsContainedBy(testDiv, template)).toBe(false) //Because template.content is a DocumentFragment
expect(domNodeIsContainedBy(testDiv, template.content)).toBe(true)

parent.appendChild(template)
expect(domNodeIsContainedBy(template, parent)).toBe(true)
expect(domNodeIsContainedBy(template, test)).toBe(true)
expect(domNodeIsContainedBy(testDiv, parent)).toBe(false) //Because template.content is a DocumentFragment
})

it('Parent Node contains child', function () {
const parent = document.createElement('div')
const child = document.createElement('span')
parent.appendChild(child)

expect(domNodeIsContainedBy(child, parent)).toBe(true)
})

it('Node not contains node', function () {
const parent = document.createElement('div')
const child = document.createElement('span')

expect(domNodeIsContainedBy(child, parent)).toBe(false)
})

it('Parent Node contains subchild', function () {
const parent = document.createElement('div')
const child = document.createElement('span')
const subchild = document.createElement('em')
parent.appendChild(child)
child.appendChild(subchild)

const node = document.createTextNode('text')

expect(domNodeIsContainedBy(subchild, parent)).toBe(true)
expect(domNodeIsContainedBy(subchild, child)).toBe(true)
expect(domNodeIsContainedBy(subchild, subchild)).toBe(true)
expect(domNodeIsContainedBy(node, parent)).toBe(false)
})
})
Comment thread
phillipc marked this conversation as resolved.

describe('setTextContent', function () {
let element: HTMLElement

Expand Down
5 changes: 3 additions & 2 deletions packages/utils/src/dom/disposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ const domDataKey = domData.nextKey()
// 1: Element
// 8: Comment
// 9: Document
const cleanableNodeTypes = { 1: true, 8: true, 9: true }
const cleanableNodeTypesWithDescendants = { 1: true, 9: true }
// 11: DocumentFragment
const cleanableNodeTypes = { 1: true, 8: true, 9: true, 11: true }
const cleanableNodeTypesWithDescendants = { 1: true, 9: true, 11: true }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function getDisposeCallbacksCollection(node: Node, createIfNotFound: boolean) {
let allDisposeCallbacks = domData.get(node, domDataKey)
Expand Down
27 changes: 20 additions & 7 deletions packages/utils/src/dom/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
//
import { arrayFirst } from '../array'

export function domNodeIsContainedBy(node: Node, containedByNode: Node) {
export function domNodeIsContainedBy(node: Node | null, containedByNode?: Node | null): boolean {
// If there is no contained node, then the node is not attached to it
// This case also happens when the shadow DOM from a HTMLTemplateElement is involved
if (!node || !containedByNode) {
return false
}
if (node === containedByNode) {
return true
}
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return false
} // Fixes issue #1162 - can't use node.contains for document fragments on IE8
}
// Fixes issue #1162 - can't use node.contains for document fragments on IE8
if (containedByNode.contains) {
return containedByNode.contains(node.nodeType !== Node.ELEMENT_NODE ? node.parentNode : node)
}
if (containedByNode.compareDocumentPosition) {
return (containedByNode.compareDocumentPosition(node) & 16) == 16
return (
(containedByNode.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_CONTAINED_BY)
=== Node.DOCUMENT_POSITION_CONTAINED_BY
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let parentNode: Node | null = node
Expand All @@ -24,8 +33,8 @@ export function domNodeIsContainedBy(node: Node, containedByNode: Node) {
return !!parentNode
}

export function domNodeIsAttachedToDocument(node) {
return domNodeIsContainedBy(node, node.ownerDocument.documentElement)
export function domNodeIsAttachedToDocument(node: Node): boolean {
return domNodeIsContainedBy(node, node.ownerDocument?.documentElement)
}

export function anyDomNodeIsAttachedToDocument(nodes) {
Expand All @@ -39,15 +48,19 @@ export function tagNameLower(element: Element) {
return element && element.tagName && element.tagName.toLowerCase()
}

export function isDomElement(obj) {
export function isTemplateTag(node): node is HTMLTemplateElement {
return node && node.nodeType === Node.ELEMENT_NODE && tagNameLower(node) === 'template'
}

export function isDomElement(obj): obj is HTMLElement {
if (window.HTMLElement) {
return obj instanceof HTMLElement
} else {
return obj && obj.tagName && obj.nodeType === Node.ELEMENT_NODE
}
}

export function isDocumentFragment(obj) {
export function isDocumentFragment(obj): obj is DocumentFragment {
if (window.DocumentFragment) {
return obj instanceof DocumentFragment
} else {
Expand Down
6 changes: 5 additions & 1 deletion packages/utils/src/dom/virtualElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// So, use node.text where available, and node.nodeValue elsewhere
import { emptyDomNode, setDomNodeChildren as setRegularDomNodeChildren } from './manipulation'
import { removeNode } from './disposal'
import { tagNameLower } from './info'
import { tagNameLower, isTemplateTag } from './info'
import * as domData from './data'
import options from '../options'

Expand Down Expand Up @@ -186,6 +186,10 @@ export function insertAfter(containerNode: Node, nodeToInsert: Node, insertAfter
}

export function firstChild(node: Node) {
if (isTemplateTag(node)) {
return node.content.firstChild
}

Comment thread
phillipc marked this conversation as resolved.
if (!isStartComment(node)) {
if (node.firstChild && isEndComment(node.firstChild)) {
throw new Error('Found invalid end comment, as the first child of ' + (node as Element).outerHTML)
Expand Down
Loading