Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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 @@ -869,10 +869,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
26 changes: 15 additions & 11 deletions packages/bind/src/applyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,8 @@ 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 {
Expand Down Expand Up @@ -398,7 +394,7 @@ function applyBindingsToNodeInternal<T>(

/**
*
* @param {HTMLElement} node
* @param {Node} node
* @param {Object} bindings
* @param {[Promise]} nodeAsyncBindingPromises
*/
Expand Down Expand Up @@ -438,7 +434,7 @@ function getBindingContext<T = any>(
}

export function applyBindingAccessorsToNode<T = any>(
node: HTMLElement,
node: Node,
bindings: Record<string, any>,
viewModelOrBindingContext?: BindingContext<T> | Observable<T> | T,
asyncBindingsApplied?: Set<any>
Expand All @@ -456,7 +452,7 @@ export function applyBindingAccessorsToNode<T = any>(
}

export function applyBindingsToNode<T = any>(
node: HTMLElement,
node: Node,
bindings: Record<string, any>,
viewModelOrBindingContext: BindingContext<T> | Observable<T> | T
): BindingResult {
Expand All @@ -473,7 +469,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 @@ -482,7 +482,7 @@ export function applyBindingsToDescendants<T = any>(

export function applyBindings<T = any>(
viewModelOrBindingContext: BindingContext<T> | Observable<T> | T,
rootNode: HTMLElement,
rootNode: Node,
extendContextCallback?: BindingContextExtendCallback<T>
): Promise<unknown> {
const asyncBindingsApplied = new Set()
Expand All @@ -493,7 +493,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 copy = template.cloneNode(true) as HTMLTemplateElement
applyBindings(outerViewModel, copy)

const innerText = (copy.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 @@ -386,7 +386,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
36 changes: 36 additions & 0 deletions packages/binding.template/spec/nativeTemplateEngineBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,42 @@ 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('applyBindings to fragment 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' } }, fragment)
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
21 changes: 21 additions & 0 deletions packages/utils/spec/domNodeDisposalBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ describe('DOM node disposal', function () {
expect(testNode.childNodes.length).toEqual(0)
})

it('Should run registered disposal callbacks when a document fragment is cleaned', function () {
let didRunCount = 0

const fragment = document.createDocumentFragment()
const childNode = document.createElement('DIV')
const spanNode = document.createElement('SPAN')
childNode.appendChild(spanNode)
fragment.appendChild(childNode)

const array = [fragment, childNode, spanNode]
array.forEach(node => {
addDisposeCallback(node, function () {
didRunCount++
})
})

expect(didRunCount).toEqual(0)
cleanNode(fragment)
expect(didRunCount).toEqual(array.length)
})

it('Should be able to remove previously-registered disposal callbacks', function () {
let didRun = false
const callback = function () {
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
15 changes: 10 additions & 5 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 Expand Up @@ -86,14 +87,18 @@ export function removeDisposeCallback(node: Node, callback: (node: Node) => void
}
}

export function cleanNode(node: Node): typeof node {
export function cleanNode(node: Node): Node {
// First clean this node, where applicable
if (cleanableNodeTypes[node.nodeType]) {
cleanSingleNode(node)

// ... then its descendants, where applicable
if (cleanableNodeTypesWithDescendants[node.nodeType] && node instanceof Element) {
cleanNodesInList(node.getElementsByTagName('*'))
if (cleanableNodeTypesWithDescendants[node.nodeType]) {
if (node instanceof Element) {
cleanNodesInList(node.getElementsByTagName('*'))
} else if (node instanceof Document || node instanceof DocumentFragment) {
Comment thread
phillipc marked this conversation as resolved.
Outdated
cleanNodesInList(node.querySelectorAll('*'))
}
Comment thread
phillipc marked this conversation as resolved.
}
}
return node
Expand Down
Loading