Skip to content

Commit 94413fe

Browse files
authored
Merge pull request #326 from knockout/component/children-as-template
Component children-as-template: skip the <template id=> when colocating
2 parents d17298c + fdb5605 commit 94413fe

6 files changed

Lines changed: 225 additions & 23 deletions

File tree

builds/knockout/spec/components/componentBindingBehaviors.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,30 @@ describe('Components: Component binding', function () {
6868
}).to.throw("Unknown component 'test-component'")
6969
})
7070

71-
it('Throws if the component definition has no template', function () {
71+
it('Throws if neither a template nor children are provided', function () {
7272
ko.components.register(testComponentName, {})
7373
expect(function () {
7474
ko.applyBindings(outerViewModel, testNode)
7575
clock.tick(1)
7676
}).to.throw("Component 'test-component' has no template")
7777
})
7878

79+
it('Uses the element children as template when no template is configured', function () {
80+
const inner = ko.observable('hello')
81+
ko.components.register(testComponentName, {
82+
viewModel: function () {
83+
return { greeting: inner }
84+
}
85+
})
86+
testNode.innerHTML =
87+
'<div data-bind="component: testComponentBindingValue">' + '<span data-bind="text: greeting"></span>' + '</div>'
88+
ko.applyBindings(outerViewModel, testNode)
89+
clock.tick(1)
90+
expectText(testNode.children[0], 'hello')
91+
inner('world')
92+
expectText(testNode.children[0], 'world')
93+
})
94+
7995
it('Controls descendant bindings', function () {
8096
ko.components.register(testComponentName, { template: 'x' })
8197
testNode.innerHTML = '<div data-bind="if: true, component: $data"></div>'

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
},
99
"scripts": {
1010
"build": "bun run --filter './packages/*' build && bun run --filter './builds/*' build",
11-
"test": "bunx vitest run",
12-
"test:ff": "VITEST_BROWSERS=firefox bunx vitest run",
11+
"test": "bunx @biomejs/biome ci . && bun run build && bunx vitest run",
12+
"test:ff": "bunx @biomejs/biome ci . && bun run build && VITEST_BROWSERS=firefox bunx vitest run",
1313
"tsc": "bunx tsc",
1414
"dts": "bunx tsc --build tsconfig.dts.json",
1515
"format": "bunx @biomejs/biome format .",

packages/binding.component/spec/componentBindingBehaviors.ts

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { DataBindProvider } from '@tko/provider.databind'
99
import { VirtualProvider } from '@tko/provider.virtual'
1010
import { ComponentProvider } from '@tko/provider.component'
1111
import { NativeProvider } from '@tko/provider.native'
12+
import { AttributeProvider } from '@tko/provider.attr'
13+
import { TextMustacheProvider, AttributeMustacheProvider } from '@tko/provider.mustache'
1214

1315
import { applyBindings, dataFor } from '@tko/bind'
1416

@@ -53,7 +55,15 @@ describe('Components: Component binding', function () {
5355
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue"></div>'
5456

5557
const provider = new MultiProvider({
56-
providers: [new ComponentProvider(), new DataBindProvider(), new VirtualProvider(), new NativeProvider()]
58+
providers: [
59+
new ComponentProvider(),
60+
new DataBindProvider(),
61+
new VirtualProvider(),
62+
new NativeProvider(),
63+
new AttributeProvider(),
64+
new TextMustacheProvider(),
65+
new AttributeMustacheProvider()
66+
]
5767
})
5868
options.bindingProviderInstance = provider
5969

@@ -93,12 +103,169 @@ describe('Components: Component binding', function () {
93103
}).to.throw("Unknown component 'test-component'")
94104
})
95105

96-
it('Throws if the component definition has no template', function () {
97-
components.register(testComponentName, {})
106+
it('Uses the element children as template when no template is configured', function () {
107+
const inner = observable('hello')
108+
components.register('hello-world', {
109+
viewModel: function () {
110+
return { greeting: inner }
111+
}
112+
})
113+
cleanups.push(() => components.unregister('hello-world'))
114+
testNode.innerHTML = '<hello-world><span ko-text="greeting"></span></hello-world>'
115+
applyBindings(outerViewModel, testNode)
116+
clock.tick(1)
117+
expectContainText(testNode.children[0], 'hello')
118+
inner('world')
119+
expectContainText(testNode.children[0], 'world')
120+
})
121+
122+
it('Throws if neither a template nor children are provided', function () {
123+
components.register('hello-world', {
124+
viewModel: function () {
125+
return {}
126+
}
127+
})
128+
cleanups.push(() => components.unregister('hello-world'))
129+
testNode.innerHTML = '<hello-world></hello-world>'
98130
expect(function () {
99131
applyBindings(outerViewModel, testNode)
100132
clock.tick(1)
101-
}).to.throw("Component 'test-component' has no template")
133+
}).to.throw("Component 'hello-world' has no template")
134+
})
135+
136+
it('Each instance gets an independent template clone — mutations to one do not bleed into siblings or subsequent instances', function () {
137+
components.register('hello-world', {
138+
template: '<p class="pristine">hello</p>'
139+
})
140+
cleanups.push(() => components.unregister('hello-world'))
141+
142+
testNode.innerHTML = '<hello-world></hello-world><hello-world></hello-world>'
143+
applyBindings(outerViewModel, testNode)
144+
clock.tick(1)
145+
146+
const [firstHost, secondHost] = Array.from(testNode.children)
147+
const first = firstHost.querySelector('p')!
148+
const second = secondHost.querySelector('p')!
149+
150+
first.className = 'mutated'
151+
first.textContent = 'MUTATED'
152+
expect(second.className).to.equal('pristine')
153+
expect(second.textContent).to.equal('hello')
154+
155+
const thirdHost = document.createElement('hello-world')
156+
testNode.appendChild(thirdHost)
157+
applyBindings(outerViewModel, thirdHost)
158+
clock.tick(1)
159+
160+
const third = thirdHost.querySelector('p')!
161+
expect(third.className).to.equal('pristine')
162+
expect(third.textContent).to.equal('hello')
163+
})
164+
165+
it('Uses children as template with mixed text and {{ }} mustache interpolation', function () {
166+
const name = observable('world')
167+
components.register('hello-world', {
168+
viewModel: function () {
169+
return { name }
170+
}
171+
})
172+
cleanups.push(() => components.unregister('hello-world'))
173+
testNode.innerHTML = '<hello-world>Hello, <strong>{{ name }}</strong>!</hello-world>'
174+
applyBindings(outerViewModel, testNode)
175+
clock.tick(1)
176+
expectContainText(testNode.children[0], 'Hello, world!')
177+
name('TKO')
178+
expectContainText(testNode.children[0], 'Hello, TKO!')
179+
})
180+
181+
it('Configured template wins — inline children are discarded', function () {
182+
components.register('hello-world', {
183+
template: '<p class="from-config">from template config</p>'
184+
})
185+
cleanups.push(() => components.unregister('hello-world'))
186+
testNode.innerHTML = '<hello-world><p class="from-children">from inline</p></hello-world>'
187+
applyBindings(outerViewModel, testNode)
188+
clock.tick(1)
189+
expectContainText(testNode.children[0], 'from template config')
190+
expect(testNode.children[0].querySelector('.from-children')).to.equal(null)
191+
})
192+
193+
it('Exposes $component, $data, and $parent inside children-as-template', function () {
194+
const parentFlag = observable('parent')
195+
components.register('hello-world', {
196+
viewModel: function () {
197+
return { greeting: 'ello' }
198+
}
199+
})
200+
cleanups.push(() => components.unregister('hello-world'))
201+
testNode.innerHTML =
202+
'<hello-world>' +
203+
'<span class="component" data-bind="text: $component.greeting"></span>' +
204+
'<span class="data" data-bind="text: $data.greeting"></span>' +
205+
'<span class="parent" data-bind="text: $parent.parentFlag"></span>' +
206+
'</hello-world>'
207+
applyBindings({ ...outerViewModel, parentFlag }, testNode)
208+
clock.tick(1)
209+
expect(testNode.children[0].querySelector('.component')!.textContent).to.equal('ello')
210+
expect(testNode.children[0].querySelector('.data')!.textContent).to.equal('ello')
211+
expect(testNode.children[0].querySelector('.parent')!.textContent).to.equal('parent')
212+
})
213+
214+
it('Nested components both use children-as-template', function () {
215+
components.register('outer-comp', {
216+
viewModel: function () {
217+
return { outerMsg: 'outer' }
218+
}
219+
})
220+
components.register('inner-comp', {
221+
viewModel: function () {
222+
return { innerMsg: 'inner' }
223+
}
224+
})
225+
cleanups.push(() => {
226+
components.unregister('outer-comp')
227+
components.unregister('inner-comp')
228+
})
229+
testNode.innerHTML =
230+
'<outer-comp>' +
231+
'<span class="outer" ko-text="outerMsg"></span>' +
232+
'<inner-comp><span class="inner" ko-text="innerMsg"></span></inner-comp>' +
233+
'</outer-comp>'
234+
applyBindings(outerViewModel, testNode)
235+
clock.tick(1)
236+
expect(testNode.children[0].querySelector('.outer')!.textContent).to.equal('outer')
237+
expect(testNode.children[0].querySelector('.inner')!.textContent).to.equal('inner')
238+
})
239+
240+
it('Rebuild via observable component name restores original children for the new component', function () {
241+
class CompB {
242+
msg = 'from-children'
243+
}
244+
components.register('comp-a', { template: '<span class="from-a">A</span>' })
245+
components.register('comp-b', { viewModel: CompB })
246+
cleanups.push(() => {
247+
components.unregister('comp-a')
248+
components.unregister('comp-b')
249+
})
250+
251+
const compName = observable('comp-a')
252+
testNode.innerHTML =
253+
'<div data-bind="component: { name: compName }">' +
254+
'<span class="from-children" data-bind="text: msg"></span>' +
255+
'</div>'
256+
applyBindings({ compName }, testNode)
257+
clock.tick(1)
258+
259+
expect(testNode.children[0].querySelector('.from-a')).to.not.equal(null)
260+
expect(testNode.children[0].querySelector('.from-children')).to.equal(null)
261+
262+
compName('comp-b')
263+
clock.tick(1)
264+
265+
expect(testNode.children[0].querySelector('.from-a')).to.equal(null)
266+
const rendered = testNode.children[0].querySelector('.from-children')
267+
expect(rendered).to.not.equal(null)
268+
expect(rendered!.textContent).to.equal('from-children')
102269
})
103270

104271
it('Controls descendant bindings', function () {

packages/binding.component/src/componentBinding.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ export default class ComponentBinding extends DescendantBindingHandler {
3333
this.computed('computeApplyComponent')
3434
}
3535

36+
/**
37+
* True when originalChildNodes contain at least one element or a text
38+
* node with non-whitespace content. Whitespace-only children are treated
39+
* as "no children" so `<my-comp> </my-comp>` still errors.
40+
*/
41+
hasMeaningfulChildren(): boolean {
42+
return this.originalChildNodes.some(
43+
n => n.nodeType === Node.ELEMENT_NODE || (n.nodeType === Node.TEXT_NODE && (n.nodeValue ?? '').trim().length > 0)
44+
)
45+
}
46+
3647
cloneTemplateIntoElement(componentName: string, template: any, element: Node) {
3748
if (!template) {
3849
throw new Error("Component '" + componentName + "' has no template")
@@ -147,12 +158,14 @@ export default class ComponentBinding extends DescendantBindingHandler {
147158

148159
const viewTemplate = componentViewModel && componentViewModel.template
149160

150-
if (!viewTemplate && !componentDefinition.template) {
151-
throw new Error("Component '" + componentName + "' has no template")
152-
}
153-
154161
if (!componentDefinition.template) {
155-
this.cloneTemplateIntoElement(componentName, viewTemplate, element)
162+
if (viewTemplate) {
163+
this.cloneTemplateIntoElement(componentName, viewTemplate, element)
164+
} else if (!this.hasMeaningfulChildren()) {
165+
throw new Error("Component '" + componentName + "' has no template")
166+
} else {
167+
this.cloneTemplateIntoElement(componentName, this.originalChildNodes, element)
168+
}
156169
}
157170

158171
if (componentViewModel instanceof LifeCycle) {

packages/utils.component/spec/ComponentABCBehaviors.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,18 @@ describe('ComponentABC', function () {
5858
components.unregister(testComponentName)
5959
})
6060

61-
it("throws when there's no overloading", function () {
61+
it('registers without overloading (children-as-template mode)', function () {
6262
class CX extends ComponentABC {}
63-
expect(() => (CX as any).register()).to.throw('overload')
63+
expect(() => (CX as any).register()).to.not.throw()
6464
})
6565

66-
it('throws when template or element is not overloaded', function () {
67-
class CX extends ComponentABC {
68-
customElementName() {
66+
it('registers when neither template nor element is overloaded (children-as-template mode)', function () {
67+
class CXTwo extends ComponentABC {
68+
static get customElementName() {
6969
return 'a-b'
7070
}
7171
}
72-
expect(() => (CX as any).register()).to.throw('overload')
72+
expect(() => (CXTwo as any).register()).to.not.throw()
7373
})
7474

7575
it('uses the class name kebab-case elementName is not overloaded', function () {

packages/utils.component/src/ComponentABC.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,29 @@ export class ComponentABC extends LifeCycle {
4545
* 2. An array of DOM nodes
4646
* 3. A document fragment
4747
* 4. An AMD module (with `{require: 'some/template'}`)
48+
* If neither this nor `element` is overloaded, the component's own
49+
* children serve as its template (children-as-template mode).
4850
* @return {mixed} One of the accepted template types for the ComponentBinding.
4951
*/
5052
static get template(): any {
5153
if ('template' in this.prototype) {
5254
return undefined
5355
}
54-
return { element: this.element }
56+
const element = this.element
57+
return element ? { element } : undefined
5558
}
5659

5760
/**
58-
* This is called by the default `template`. Overload this to return:
61+
* Overload this to return:
5962
* 1. The element ID
6063
* 2. A DOM node itself
61-
* @return {string|HTMLElement} either the element ID or actual element.
64+
* Leave unset to use children-as-template mode — the component's own
65+
* instance children become its template.
66+
* @return {string|HTMLElement|undefined} the element ID, actual element,
67+
* or undefined to opt into children-as-template.
6268
*/
63-
static get element() {
64-
throw new Error('[ComponentABC] `element` must be overloaded.')
69+
static get element(): string | HTMLElement | undefined {
70+
return undefined
6571
}
6672

6773
/**

0 commit comments

Comments
 (0)