Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
34 changes: 34 additions & 0 deletions .changeset/shadow-root-template-fragment-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@tko/bind": minor
"@tko/utils": minor
"@tko/binding.foreach": patch
---

Allow `applyBindings` to run on `HTMLTemplateElement`, `DocumentFragment`,
and shadow-root subtrees

`applyBindings` and `applyBindingsToDescendants` now accept any `Node` whose
`nodeType` is `ELEMENT_NODE`, `COMMENT_NODE`, or `DOCUMENT_FRAGMENT_NODE`,
which makes it possible to bind a shadow root, a document fragment, or a
`<template>` element directly. The binding engine recurses into
`template.content` as if it were a normal subtree.

`@tko/utils`:

- `cleanNode` now disposes nodes in `Document` / `DocumentFragment`
subtrees (nodeType 9 and 11), and recurses into every nested
`HTMLTemplateElement.content` so bindings created inside templates do
not leak. `instanceof` checks are replaced with `nodeType` checks for
cross-runtime safety.
- `virtualElements.firstChild`, `childNodes`, `emptyNode`, and
`setDomNodeChildren` now operate on `template.content` for
`HTMLTemplateElement`, matching the new template-aware binding path.
- `domNodeIsContainedBy` accepts `null | undefined` and uses a proper
`compareDocumentPosition` bitmask test.
- New type predicate `isTemplateTag(node)`; `isDomElement` /
`isDocumentFragment` now narrow types.

**Behavior change for `@tko/build.knockout`:** legacy KO did not recurse
into `<template>` elements. TKO now does. Apps that have `data-bind`
syntax inside `<template>` content and previously relied on it being
left alone will now have those bindings applied.
6 changes: 4 additions & 2 deletions builds/knockout/spec/bindingAttributeBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,12 @@ describe('Binding attribute syntax', function () {
})

it('<template>', function () {
document.createElement('template') // For old IE
// TKO recurses into <template>.content (unlike legacy KO, which left
// <template> contents alone). Verifies the build.knockout bundle
// matches the package-level behavior.
testNode.innerHTML = '<p>Hello</p><template>test</template><p>Goodbye</p>'
ko.applyBindings({ sometext: 'hello' }, testNode)
expectContainHtml(testNode, '<p>replaced</p><template>test</template><p>replaced</p>')
expectContainHtml(testNode, '<p>replaced</p><template>replaced</template><p>replaced</p>')
})
})

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 @@ -68,7 +68,7 @@

let didInit = false
bindingHandlers.test = {
init: function (element, valueAccessor, allBindings, viewModel) {

Check warning on line 71 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.

Check warning on line 71 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.
expect(element.id).to.equal('testElement')
expect(viewModel).to.equal(undefined)
didInit = true
Expand All @@ -87,7 +87,7 @@
let didInit = false
const suppliedViewModel = {}
bindingHandlers.test = {
init: function (element, valueAccessor, allBindings, viewModel) {

Check warning on line 90 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.

Check warning on line 90 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.
expect(element.id).to.equal('testElement')
expect(viewModel).to.equal(suppliedViewModel)
didInit = true
Expand All @@ -102,7 +102,7 @@
let didInit = false
const suppliedViewModel = {}
bindingHandlers.test = {
init: function (element, valueAccessor, allBindings, viewModel) {

Check warning on line 105 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.

Check warning on line 105 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.
expect(element.id).to.equal('testElement')
expect(viewModel).to.equal(suppliedViewModel)
didInit = true
Expand Down Expand Up @@ -160,7 +160,7 @@

it('Should produce a meaningful error if a binding value contains invalid JavaScript', function () {
bindingHandlers.test = {
init: function (element, valueAccessor) {

Check warning on line 163 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/correctness/noUnusedFunctionParameters

This parameter is unused.
valueAccessor()
}
}
Expand Down Expand Up @@ -251,7 +251,7 @@
bindingHandlers.test = {
update: function () {
if (observable() === 42) {
throw new Error('Observable: ' + observable())

Check notice on line 254 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/style/useTemplate

Template literals are preferred over string concatenation.
}
}
}
Expand Down Expand Up @@ -341,7 +341,7 @@
const methodsInvoked = new Array()
bindingHandlers.test1 = bindingHandlers.test2 = {
init: function (element, valueAccessor) {
methodsInvoked.push('init' + valueAccessor())

Check notice on line 344 in packages/bind/spec/bindingAttributeBehaviors.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

lint/style/useTemplate

Template literals are preferred over string concatenation.
},
update: function (element, valueAccessor) {
methodsInvoked.push('update' + valueAccessor())
Expand Down Expand Up @@ -868,10 +868,9 @@
})

it('<template>', function () {
document.createElement('template') // For old IE
testNode.innerHTML = '<p>Hello</p><template>test</template><p>Goodbye</p>'
applyBindings({ sometext: 'hello' }, testNode)
expectContainHtml(testNode, '<p>replaced</p><template>test</template><p>replaced</p>')
expectContainHtml(testNode, '<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 @@ -1394,6 +1394,39 @@ describe('Components: Component binding', function () {
expect(innerText).to.deep.equal(`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).to.deep.equal(`X beep Y Gamma Zeta Q`)

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

it('inserts all component template nodes in an unnamed (default) slot', function (ctx: any) {
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 @@ -378,7 +378,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
11 changes: 11 additions & 0 deletions packages/binding.template/spec/foreachBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ 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)
expectContainHtml(
(testNode.childNodes[0] as HTMLTemplateElement).content.firstChild,
'<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
52 changes: 52 additions & 0 deletions packages/binding.template/spec/nativeTemplateEngineBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,58 @@ 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)
expectContainText(template.content.childNodes[0], '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)
expectContainText(template.content.childNodes[0], 'Value: abc')
})

it('should set $data when binding a DIV that is inside a 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)
expectContainText(div.childNodes[0], 'Value: abc')
})

it('should bind a node attached to a ShadowRoot', function () {
// Mirrors the original PR motivation: applyBindings on a node that
// lives inside a ShadowRoot should resolve $data and set up bindings
// the same way it does for a node in the light DOM.
const host = document.createElement('div')
testNode.appendChild(host)
const shadow = host.attachShadow({ mode: 'open' })
const inner = document.createElement('div') as HTMLDivElement
inner.innerHTML =
"<div data-bind='template: { data: someItem }'>" + "Value: <span data-bind='text: $data.val'></span>" + '</div>'
shadow.appendChild(inner)

applyBindings({ someItem: { val: 'abc' } }, inner)
expectContainText(inner.childNodes[0], 'Value: abc')
})
})

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
45 changes: 45 additions & 0 deletions packages/utils/spec/domNodeDisposalBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ describe('DOM node disposal', function () {
expect(testNode.childNodes.length).to.equal(0)
})

it('Should run registered disposal callbacks on descendants inside a <template>.content fragment when the template host subtree is cleaned', function () {
// Regression: <template>.content is a detached DocumentFragment that
// getElementsByTagName('*') / querySelectorAll('*') do not traverse,
// so cleanNode must recurse into it explicitly. Without this, bindings
// and subscriptions inside templates leak.
const template = document.createElement('template') as HTMLTemplateElement
const innerDiv = document.createElement('div')
const innerSpan = document.createElement('span')
innerDiv.appendChild(innerSpan)
template.content.appendChild(innerDiv)
testNode.appendChild(template)

let didRunCount = 0
const callback = function () {
didRunCount++
}
addDisposeCallback(innerDiv, callback)
addDisposeCallback(innerSpan, callback)

expect(didRunCount).to.equal(0)
cleanNode(testNode)
expect(didRunCount).to.equal(2)
})

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).to.equals(0)
cleanNode(fragment)
expect(didRunCount).to.equals(array.length)
})

it('Should be able to remove previously-registered disposal callbacks', function () {
let didRun = false
const callback = function () {
Expand Down
Loading
Loading