-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathcomponentBindingBehaviors.ts
More file actions
1617 lines (1393 loc) · 62.2 KB
/
componentBindingBehaviors.ts
File metadata and controls
1617 lines (1393 loc) · 62.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { options, tasks, domData, triggerEvent, cleanNode } from '@tko/utils'
import { observableArray, observable, isWritableObservable } from '@tko/observable'
import type { ObservableArray } from '@tko/observable'
import { MultiProvider } from '@tko/provider.multi'
import { DataBindProvider } from '@tko/provider.databind'
import { VirtualProvider } from '@tko/provider.virtual'
import { ComponentProvider } from '@tko/provider.component'
import { NativeProvider } from '@tko/provider.native'
import { AttributeProvider } from '@tko/provider.attr'
import { TextMustacheProvider, AttributeMustacheProvider } from '@tko/provider.mustache'
import { applyBindings, dataFor } from '@tko/bind'
import { bindings as coreBindings } from '@tko/binding.core'
import { bindings as templateBindings } from '@tko/binding.template'
import { bindings as ifBindings } from '@tko/binding.if'
import { JsxObserver } from '@tko/utils.jsx'
import { bindings as componentBindings } from '../dist'
import components from '@tko/utils.component'
import { expect } from 'chai'
import sinon from 'sinon'
import {
expectContainHtml,
expectContainText,
prepareTestNode,
restoreAfter,
useMockForTasks
} from '../../utils/helpers/mocha-test-helpers'
import { isHappyDom } from '../../utils/helpers/test-env'
describe('Components: Component binding', function () {
let testComponentName = 'test-component',
testComponentBindingValue,
testComponentParams,
outerViewModel
let testNode: HTMLElement
let clock: sinon.SinonFakeTimers
let cleanups: Array<() => void>
beforeEach(function () {
cleanups = []
clock = sinon.useFakeTimers()
useMockForTasks(cleanups)
testNode = prepareTestNode()
testComponentParams = {}
testComponentBindingValue = { name: testComponentName, params: testComponentParams }
outerViewModel = { testComponentBindingValue: testComponentBindingValue, isOuterViewModel: true }
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue"></div>'
const provider = new MultiProvider({
providers: [
new ComponentProvider(),
new DataBindProvider(),
new VirtualProvider(),
new NativeProvider(),
new AttributeProvider(),
new TextMustacheProvider(),
new AttributeMustacheProvider()
]
})
options.bindingProviderInstance = provider
provider.bindingHandlers.set(templateBindings)
provider.bindingHandlers.set(ifBindings)
provider.bindingHandlers.set(coreBindings)
provider.bindingHandlers.set(componentBindings)
})
afterEach(function () {
expect(tasks.resetForTesting()).to.equal(0)
while (cleanups.length) {
cleanups.pop()!()
}
clock.restore()
components.unregister(testComponentName)
})
it('Throws if no name is specified (name provided directly)', function () {
testNode.innerHTML = '<div data-bind="component: \'\'"></div>'
expect(function () {
applyBindings(null, testNode)
}).to.throw('No component name specified')
})
it('Throws if no name is specified (using options object)', function () {
delete testComponentBindingValue.name
expect(function () {
applyBindings(outerViewModel, testNode)
}).to.throw('No component name specified')
})
it('Throws if the component name is unknown', function () {
expect(function () {
applyBindings(outerViewModel, testNode)
clock.tick(1)
}).to.throw("Unknown component 'test-component'")
})
it('Uses the element children as template when no template is configured', function () {
const inner = observable('hello')
components.register('hello-world', {
viewModel: function () {
return { greeting: inner }
}
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML = '<hello-world><span ko-text="greeting"></span></hello-world>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode.children[0], 'hello')
inner('world')
expectContainText(testNode.children[0], 'world')
})
it('Throws if neither a template nor children are provided', function () {
components.register('hello-world', {
viewModel: function () {
return {}
}
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML = '<hello-world></hello-world>'
expect(function () {
applyBindings(outerViewModel, testNode)
clock.tick(1)
}).to.throw("Component 'hello-world' has no template")
})
it('Each instance gets an independent template clone — mutations to one do not bleed into siblings or subsequent instances', function () {
components.register('hello-world', {
template: '<p class="pristine">hello</p>'
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML = '<hello-world></hello-world><hello-world></hello-world>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
const [firstHost, secondHost] = Array.from(testNode.children)
const first = firstHost.querySelector('p')!
const second = secondHost.querySelector('p')!
first.className = 'mutated'
first.textContent = 'MUTATED'
expect(second.className).to.equal('pristine')
expect(second.textContent).to.equal('hello')
const thirdHost = document.createElement('hello-world')
testNode.appendChild(thirdHost)
applyBindings(outerViewModel, thirdHost)
clock.tick(1)
const third = thirdHost.querySelector('p')!
expect(third.className).to.equal('pristine')
expect(third.textContent).to.equal('hello')
})
it('Uses children as template with mixed text and {{ }} mustache interpolation', function () {
const name = observable('world')
components.register('hello-world', {
viewModel: function () {
return { name }
}
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML = '<hello-world>Hello, <strong>{{ name }}</strong>!</hello-world>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode.children[0], 'Hello, world!')
name('TKO')
expectContainText(testNode.children[0], 'Hello, TKO!')
})
it('Configured template wins — inline children are discarded', function () {
components.register('hello-world', {
template: '<p class="from-config">from template config</p>'
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML = '<hello-world><p class="from-children">from inline</p></hello-world>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode.children[0], 'from template config')
expect(testNode.children[0].querySelector('.from-children')).to.equal(null)
})
it('Exposes $component, $data, and $parent inside children-as-template', function () {
const parentFlag = observable('parent')
components.register('hello-world', {
viewModel: function () {
return { greeting: 'ello' }
}
})
cleanups.push(() => components.unregister('hello-world'))
testNode.innerHTML =
'<hello-world>' +
'<span class="component" data-bind="text: $component.greeting"></span>' +
'<span class="data" data-bind="text: $data.greeting"></span>' +
'<span class="parent" data-bind="text: $parent.parentFlag"></span>' +
'</hello-world>'
applyBindings({ ...outerViewModel, parentFlag }, testNode)
clock.tick(1)
expect(testNode.children[0].querySelector('.component')!.textContent).to.equal('ello')
expect(testNode.children[0].querySelector('.data')!.textContent).to.equal('ello')
expect(testNode.children[0].querySelector('.parent')!.textContent).to.equal('parent')
})
it('Nested components both use children-as-template', function () {
components.register('outer-comp', {
viewModel: function () {
return { outerMsg: 'outer' }
}
})
components.register('inner-comp', {
viewModel: function () {
return { innerMsg: 'inner' }
}
})
cleanups.push(() => {
components.unregister('outer-comp')
components.unregister('inner-comp')
})
testNode.innerHTML =
'<outer-comp>' +
'<span class="outer" ko-text="outerMsg"></span>' +
'<inner-comp><span class="inner" ko-text="innerMsg"></span></inner-comp>' +
'</outer-comp>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expect(testNode.children[0].querySelector('.outer')!.textContent).to.equal('outer')
expect(testNode.children[0].querySelector('.inner')!.textContent).to.equal('inner')
})
it('Rebuild via observable component name restores original children for the new component', function () {
class CompB {
msg = 'from-children'
}
components.register('comp-a', { template: '<span class="from-a">A</span>' })
components.register('comp-b', { viewModel: CompB })
cleanups.push(() => {
components.unregister('comp-a')
components.unregister('comp-b')
})
const compName = observable('comp-a')
testNode.innerHTML =
'<div data-bind="component: { name: compName }">' +
'<span class="from-children" data-bind="text: msg"></span>' +
'</div>'
applyBindings({ compName }, testNode)
clock.tick(1)
expect(testNode.children[0].querySelector('.from-a')).to.not.equal(null)
expect(testNode.children[0].querySelector('.from-children')).to.equal(null)
compName('comp-b')
clock.tick(1)
expect(testNode.children[0].querySelector('.from-a')).to.equal(null)
const rendered = testNode.children[0].querySelector('.from-children')
expect(rendered).to.not.equal(null)
expect(rendered!.textContent).to.equal('from-children')
})
it('Controls descendant bindings', function () {
components.register(testComponentName, { template: 'x' })
testNode.innerHTML = '<div data-bind="if: true, component: $data"></div>'
expect(function () {
applyBindings(testComponentName, testNode)
}).to.throw('Multiple bindings (if and component) are trying to control descendant bindings of the same element.')
// Even though applyBindings threw an exception, the component still gets bound (asynchronously)
clock.tick(1)
})
it("Replaces the element's contents with a clone of the template", function () {
const testTemplate = document.createDocumentFragment()
testTemplate.appendChild(document.createElement('div'))
testTemplate.appendChild(document.createTextNode(' '))
testTemplate.appendChild(document.createElement('span')) //TODO good example for ASI..
expect(testTemplate.childNodes.length).to.equal(3)
;(testTemplate.childNodes[0] as HTMLElement).innerHTML = 'hello'
;(testTemplate.childNodes[2] as HTMLElement).innerHTML = 'world'
components.register(testComponentName, { template: testTemplate })
// Bind using just the component name since we're not setting any params
applyBindings({ testComponentBindingValue: testComponentName }, testNode)
// See the template asynchronously shows up
clock.tick(1)
expectContainHtml(testNode.children[0], '<div>hello</div> <span>world</span>')
expect(testTemplate.children[0].childNodes.length).to.equal(1)
// Also be sure it's a clone
expect(testNode.children[0].children[0]).to.not.equal(testTemplate[0])
})
it("Passes params and componentInfo (with prepopulated element and templateNodes) to the component's viewmodel factory", function () {
const componentConfig = {
template: '<div data-bind="text: 123">I have been prepopulated and not bound yet</div>',
viewModel: {
createViewModel: function (params, componentInfo) {
expectContainText(componentInfo.element, 'I have been prepopulated and not bound yet')
expect(params).to.equal(testComponentParams)
expect(componentInfo.templateNodes.length).to.deep.equal(3)
expectContainText(componentInfo.templateNodes[0], 'Here are some ')
expectContainText(componentInfo.templateNodes[1], 'template')
expectContainText(componentInfo.templateNodes[2], ' nodes')
expect(componentInfo.templateNodes[1].tagName.toLowerCase()).to.deep.equal('em')
// verify that createViewModel is the same function and was called with the component definition as the context
expect(this.createViewModel).to.equal(componentConfig.viewModel.createViewModel)
expect(this.template).to.not.equal(undefined)
componentInfo.element.children[0].setAttribute('data-bind', 'text: someValue')
return { someValue: 'From the viewmodel' }
}
}
}
testNode.innerHTML =
'<div data-bind="component: testComponentBindingValue">Here are some <em>template</em> nodes</div>'
components.register(testComponentName, componentConfig)
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode, 'From the viewmodel')
})
it('Handles absence of viewmodel by using the params', function () {
components.register(testComponentName, { template: '<div data-bind="text: myvalue"></div>' })
testComponentParams.myvalue = 'some parameter value'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainHtml(testNode.children[0], '<div data-bind="text: myvalue">some parameter value</div>')
})
it('Injects and binds the component synchronously if it is flagged as synchronous and loads synchronously', function () {
components.register(testComponentName, {
synchronous: true,
template: '<div data-bind="text: myvalue"></div>',
viewModel: function () {
this.myvalue = 123
}
})
// Notice the absence of any fake-clock tick here. This is synchronous.
applyBindings(outerViewModel, testNode)
expectContainHtml(testNode.children[0], '<div data-bind="text: myvalue">123</div>')
})
it('Injects and binds the component synchronously if it is flagged as synchronous and already cached, even if it previously loaded asynchronously', function () {
// Set up a component that loads asynchronously, but is flagged as being injectable synchronously
restoreAfter(cleanups, window as any, 'require')
// var requireCallbacks = {};
window.require = function (moduleNames, callback) {
expect(moduleNames[0]).to.equal('testViewModelModule')
setTimeout(function () {
const constructor = function (params) {
this.viewModelProperty = params
}
callback(constructor)
}, 0)
}
components.register(testComponentName, {
synchronous: true,
template: '<div data-bind="text: viewModelProperty"></div>',
viewModel: { require: 'testViewModelModule' }
})
const testList = observableArray(['first'])
testNode.innerHTML =
'<div data-bind="foreach: testList">' +
'<div data-bind="component: { name: \'test-component\', params: $data }"></div>' +
'</div>'
// First injection is async, because the loader completes asynchronously
applyBindings({ testList: testList }, testNode)
expectContainText(testNode.children[0], '')
clock.tick(0)
expectContainText(testNode.children[0], 'first')
// Second (cached) injection is synchronous, because the component config says so.
// Notice the absence of any fake-clock tick here. This is synchronous.
testList.push('second')
expectContainText(testNode.children[0], 'firstsecond', /* ignoreSpaces */ true) // Ignore spaces because old-IE is inconsistent
})
it('Creates a binding context with the correct parent', function () {
components.register(testComponentName, {
template: 'Parent is outer view model: <span data-bind="text: $parent.isOuterViewModel"></span>'
})
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode.children[0], 'Parent is outer view model: true')
})
it('Creates a binding context with $componentTemplateNodes giving the original child nodes', function () {
components.register(testComponentName, {
template: 'Start<span data-bind="template: { nodes: $componentTemplateNodes }"></span>End'
})
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue"><em>original</em> child nodes</div>'
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainHtml(
testNode.children[0],
'start<span data-bind="template: { nodes: $componenttemplatenodes }"><em>original</em> child nodes</span>end'
)
})
it('Creates a binding context with $component to reference the closest component viewmodel', function () {
cleanups.push(() => {
components.unregister('sub-component')
})
components.register(testComponentName, {
template:
'<span data-bind="with: { childContext: 123 }">' +
'In child context <!-- ko text: childContext --><!-- /ko -->, ' +
'inside component with property <!-- ko text: $component.componentProp --><!-- /ko -->. ' +
'<div data-bind="component: \'sub-component\'"></div>' +
'</span>',
viewModel: function () {
return { componentProp: 456 }
}
})
// See it works with nesting - $component always refers to the *closest* component root
components.register('sub-component', {
template: 'Now in sub-component with property <!-- ko text: $component.componentProp --><!-- /ko -->.',
viewModel: function () {
return { componentProp: 789 }
}
})
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(
testNode.children[0],
'In child context 123, inside component with property 456. Now in sub-component with property 789.',
/* ignoreSpaces */ true
) // Ignore spaces because old-IE is inconsistent
})
it('Passes nonobservable params to the component', function () {
// Set up a component that logs its constructor params
const receivedParams = new Array()
components.register(testComponentName, {
viewModel: function (params) {
receivedParams.push(params)
},
template: 'Ignored'
})
testComponentParams.someValue = 123
// Instantiate it
applyBindings(outerViewModel, testNode)
clock.tick(1)
// See the params arrived as expected
expect(receivedParams).to.deep.equal([testComponentParams])
expect(testComponentParams.someValue).to.equal(123) // Just to be sure it doesn't get mutated
})
it('Passes through observable params without unwrapping them (so a given component instance can observe them changing)', function () {
// Set up a component that logs its constructor params
const receivedParams = new Array()
components.register(testComponentName, {
viewModel: function (params) {
receivedParams.push(params)
this.someValue = params.someValue
},
template: 'The value is <span data-bind="text: someValue"></span>.'
})
testComponentParams.someValue = observable(123)
// Instantiate it
applyBindings(outerViewModel, testNode)
clock.tick(1)
// See the params arrived as expected
expect(receivedParams).to.deep.equal([testComponentParams])
expectContainText(testNode, 'The value is 123.')
// Mutating the observable doesn't trigger creation of a new component
testComponentParams.someValue(456)
expect(receivedParams.length).to.equal(1) // i.e., no additional constructor call occurred
expectContainText(testNode, 'The value is 456.')
})
it('Supports observable component names, rebuilding the component if the name changes, disposing the old viewmodel and nodes', function () {
cleanups.push(() => {
components.unregister('component-alpha')
components.unregister('component-beta')
})
function alphaViewModel(params) {
this.alphaValue = params.suppliedValue
}
function betaViewModel(params) {
this.betaValue = params.suppliedValue
}
alphaViewModel.prototype.dispose = function () {
expect(arguments.length).to.equal(0)
this.alphaWasDisposed = true
// Disposal happens *before* the DOM is torn down, in case some custom cleanup is required
// Note that you'd have to have captured the element via createViewModel, so this is only
// for extensibility scenarios - we don't generally recommend that component viewmodels
// should interact directly with their DOM, as that breaks MVVM encapsulation.
expectContainText(testNode, 'Alpha value is 234.')
}
components.register('component-alpha', {
viewModel: alphaViewModel,
template: '<div class="alpha">Alpha value is <span data-bind="text: alphaValue"></span>.</div>'
})
components.register('component-beta', {
viewModel: betaViewModel,
template: '<div class="beta">Beta value is <span data-bind="text: betaValue"></span>.</div>'
})
// Instantiate the first component
testComponentBindingValue.name = observable('component-alpha')
testComponentParams.suppliedValue = observable(123)
applyBindings(outerViewModel, testNode)
clock.tick(1)
// See it appeared, and the expected subscriptions were registered
expect(testNode.firstChild).to.not.equal(null)
const firstAlphaTemplateNode = testNode.firstChild?.firstChild as HTMLElement,
alphaViewModelInstance = dataFor(firstAlphaTemplateNode)
expect(firstAlphaTemplateNode.className).to.equal('alpha')
expectContainText(testNode, 'Alpha value is 123.')
expect(testComponentBindingValue.name.getSubscriptionsCount()).to.equal(1)
expect(testComponentParams.suppliedValue.getSubscriptionsCount()).to.equal(1)
expect(alphaViewModelInstance.alphaWasDisposed).to.not.equal(true)
// Store some data on a DOM node so we can check it was cleaned later
domData.set(firstAlphaTemplateNode, 'TestValue', 'Hello')
// Mutating an observable param doesn't change the set of subscriptions or replace the DOM nodes
testComponentParams.suppliedValue(234)
expectContainText(testNode, 'Alpha value is 234.')
expect(testComponentBindingValue.name.getSubscriptionsCount()).to.equal(1)
expect(testComponentParams.suppliedValue.getSubscriptionsCount()).to.equal(1)
expect(testNode.firstChild).to.not.equal(null)
expect(testNode.firstChild?.firstChild).to.equal(firstAlphaTemplateNode) // Same node
expect(domData.get(firstAlphaTemplateNode, 'TestValue')).to.equal('Hello') // Not cleaned
expect(alphaViewModelInstance.alphaWasDisposed).to.not.equal(true)
// Can switch to the other component by observably changing the component name,
// but it happens asynchronously (because the component has to be loaded)
testComponentBindingValue.name('component-beta')
expectContainText(testNode, 'Alpha value is 234.')
clock.tick(1)
expectContainText(testNode, 'Beta value is 234.')
// Cleans up by disposing obsolete subscriptions, viewmodels, and cleans DOM nodes
expect(testComponentBindingValue.name.getSubscriptionsCount()).to.equal(1)
expect(testComponentParams.suppliedValue.getSubscriptionsCount()).to.equal(1)
expect(domData.get(firstAlphaTemplateNode, 'TestValue')).to.equal(undefined) // Got cleaned
expect(alphaViewModelInstance.alphaWasDisposed).to.equal(true)
})
it('Supports binding to an observable that contains name/params, rebuilding the component if that observable changes, disposing the old viewmodel and nodes', function () {
cleanups.push(() => {
components.unregister('component-alpha')
components.unregister('component-beta')
})
function alphaViewModel(params) {
this.alphaValue = params.suppliedValue
}
function betaViewModel(params) {
this.betaValue = params.suppliedValue
}
alphaViewModel.prototype.dispose = function () {
expect(arguments.length).to.equal(0)
this.alphaWasDisposed = true
// Disposal happens *before* the DOM is torn down, in case some custom cleanup is required
// Note that you'd have to have captured the element via createViewModel, so this is only
// for extensibility scenarios - we don't generally recommend that component viewmodels
// should interact directly with their DOM, as that breaks MVVM encapsulation.
expectContainText(testNode, 'Alpha value is 123.')
}
components.register('component-alpha', {
viewModel: alphaViewModel,
template: '<div class="alpha">Alpha value is <span data-bind="text: alphaValue"></span>.</div>'
})
components.register('component-beta', {
viewModel: betaViewModel,
template: '<div class="beta">Beta value is <span data-bind="text: betaValue"></span>.</div>'
})
outerViewModel.testComponentBindingValue = observable({ name: 'component-alpha', params: { suppliedValue: 123 } })
// Instantiate the first component
applyBindings(outerViewModel, testNode)
clock.tick(1)
// See it appeared, and the expected subscriptions were registered
expect(testNode.firstChild).to.not.equal(null)
const firstAlphaTemplateNode = testNode.firstChild?.firstChild as HTMLElement,
alphaViewModelInstance = dataFor(firstAlphaTemplateNode)
expect(firstAlphaTemplateNode.className).to.equal('alpha')
expectContainText(testNode, 'Alpha value is 123.')
expect(outerViewModel.testComponentBindingValue.getSubscriptionsCount()).to.equal(1)
expect(alphaViewModelInstance.alphaWasDisposed).to.not.equal(true)
// Store some data on a DOM node so we can check it was cleaned later
domData.set(firstAlphaTemplateNode, 'TestValue', 'Hello')
// Can switch to the other component by changing observable,
// but it happens asynchronously (because the component has to be loaded)
outerViewModel.testComponentBindingValue({ name: 'component-beta', params: { suppliedValue: 456 } })
expectContainText(testNode, 'Alpha value is 123.')
clock.tick(1)
expectContainText(testNode, 'Beta value is 456.')
// Cleans up by disposing obsolete subscriptions, viewmodels, and cleans DOM nodes
expect(outerViewModel.testComponentBindingValue.getSubscriptionsCount()).to.equal(1)
expect(domData.get(firstAlphaTemplateNode, 'TestValue')).to.equal(undefined) // Got cleaned
expect(alphaViewModelInstance.alphaWasDisposed).to.equal(true)
})
it('Rebuilds the component if params change in a way that is forced to unwrap inside the binding, disposing the old viewmodel and nodes', function () {
function testViewModel(params) {
this.myData = params.someData
}
testViewModel.prototype.dispose = function () {
this.wasDisposed = true
}
components.register(testComponentName, {
viewModel: testViewModel,
template: '<div>Value is <span data-bind="text: myData"></span>.</div>'
})
// Instantiate the first component, via a binding that unwraps an observable before it reaches the component
const someObservable = observable('First')
testNode.innerHTML =
'<div data-bind="component: { name: \'' +
testComponentName +
'\', params: { someData: someObservable() } }"></div>'
applyBindings({ someObservable: someObservable }, testNode)
clock.tick(1)
expect(testNode.firstChild).to.not.equal(null)
const firstTemplateNode = testNode.firstChild?.firstChild as HTMLElement,
firstViewModelInstance = dataFor(firstTemplateNode)
expect(firstViewModelInstance instanceof testViewModel).to.equal(true)
expectContainText(testNode, 'Value is First.')
expect(firstViewModelInstance.wasDisposed).to.not.equal(true)
domData.set(firstTemplateNode, 'TestValue', 'Hello')
// Make an observable change that forces the component to rebuild (asynchronously, for consistency)
someObservable('Second')
expectContainText(testNode, 'Value is First.')
expect(firstViewModelInstance.wasDisposed).to.not.equal(true)
expect(domData.get(firstTemplateNode, 'TestValue')).to.equal('Hello')
clock.tick(1)
expectContainText(testNode, 'Value is Second.')
expect(firstViewModelInstance.wasDisposed).to.equal(true)
expect(domData.get(firstTemplateNode, 'TestValue')).to.equal(undefined)
// New viewmodel is a new instance
const secondViewModelInstance = dataFor(testNode.firstChild?.firstChild as HTMLElement)
expect(secondViewModelInstance instanceof testViewModel).to.equal(true)
expect(secondViewModelInstance).to.not.equal(firstViewModelInstance)
})
it('Is possible to pass expressions that can vary observably and evaluate as writable observable instances', function () {
// This spec is copied, with small modifications, from customElementBehaviors.js to show that the same component
// definition can be used with the component binding and with custom elements.
let constructorCallCount = 0
components.register('test-component', {
template: '<input data-bind="value: myval"/>',
viewModel: function (params) {
constructorCallCount++
this.myval = params.somevalue
// See we received a writable observable
expect(isWritableObservable(this.myval)).to.equal(true)
}
})
// Bind to a viewmodel with nested observables; see the expression is evaluated as expected
// The component itself doesn't have to know or care that the supplied value is nested - the
// custom element syntax takes care of producing a single computed property that gives the
// unwrapped inner value.
const innerObservable = observable('inner1'),
outerObservable = observable({ inner: innerObservable })
testNode.innerHTML =
'<div data-bind="component: { name: \'' + testComponentName + '\', params: { somevalue: outer().inner } }"></div>'
applyBindings({ outer: outerObservable }, testNode)
clock.tick(1)
expect((testNode.children[0].children[0] as HTMLInputElement).value).to.deep.equal('inner1')
expect(outerObservable.getSubscriptionsCount()).to.equal(1)
expect(innerObservable.getSubscriptionsCount()).to.equal(1)
expect(constructorCallCount).to.equal(1)
// See we can mutate the inner value and see the result show up
innerObservable('inner2')
expect((testNode.children[0].children[0] as HTMLInputElement).value).to.deep.equal('inner2')
expect(outerObservable.getSubscriptionsCount()).to.equal(1)
expect(innerObservable.getSubscriptionsCount()).to.equal(1)
expect(constructorCallCount).to.equal(1)
// See that we can mutate the observable from within the component
;(testNode.children[0].children[0] as HTMLInputElement).value = 'inner3'
triggerEvent(testNode.children[0].children[0], 'change')
expect(innerObservable()).to.deep.equal('inner3')
// See we can mutate the outer value and see the result show up (cleaning subscriptions to the old inner value)
const newInnerObservable = observable('newinner')
outerObservable({ inner: newInnerObservable })
clock.tick(1) // modifying the outer observable causes the component to reload, which happens asynchronously
expect((testNode.children[0].children[0] as HTMLInputElement).value).to.deep.equal('newinner')
expect(outerObservable.getSubscriptionsCount()).to.equal(1)
expect(innerObservable.getSubscriptionsCount()).to.equal(0)
expect(newInnerObservable.getSubscriptionsCount()).to.equal(1)
expect(constructorCallCount).to.equal(2)
// See that we can mutate the new observable from within the component
;(testNode.children[0].children[0] as HTMLInputElement).value = 'newinner2'
triggerEvent(testNode.children[0].children[0], 'change')
expect(newInnerObservable()).to.deep.equal('newinner2')
expect(innerObservable()).to.deep.equal('inner3') // original one hasn't changed
// See that subscriptions are disposed when the component is
cleanNode(testNode)
expect(outerObservable.getSubscriptionsCount()).to.equal(0)
expect(innerObservable.getSubscriptionsCount()).to.equal(0)
expect(newInnerObservable.getSubscriptionsCount()).to.equal(0)
})
it('Disposes the viewmodel if the element is cleaned', function () {
class TestViewModel {
wasDisposed: boolean
dispose() {
this.wasDisposed = true
}
}
components.register(testComponentName, { viewModel: TestViewModel, template: '<div>Ignored</div>' })
// Bind an instance of the component; grab its viewmodel
applyBindings(outerViewModel, testNode)
clock.tick(1)
expect(testNode.firstChild).to.not.equal(null)
const firstTemplateNode = testNode.firstChild?.firstChild as HTMLElement,
viewModelInstance = dataFor(firstTemplateNode)
expect(viewModelInstance instanceof TestViewModel).to.equal(true)
expect(viewModelInstance.wasDisposed).to.not.equal(true)
// See that cleaning the associated element automatically disposes the viewmodel
cleanNode(testNode.firstChild!)
expect(viewModelInstance.wasDisposed).to.equal(true)
})
it('Does not inject the template or instantiate the viewmodel if the element was cleaned before component loading completed', function () {
let numConstructorCalls = 0
components.register(testComponentName, {
viewModel: function () {
numConstructorCalls++
},
template: '<div>Should not be used</div>'
})
// Bind an instance of the component; grab its viewmodel
applyBindings(outerViewModel, testNode)
// Before the component finishes loading, clean the DOM
cleanNode(testNode.firstChild!)
// Now wait and see that, after loading finishes, the component wasn't used
clock.tick(1)
expect(numConstructorCalls).to.equal(0)
expectContainHtml(testNode.firstChild, '')
})
it('Disregards component load completions that are no longer relevant', function () {
// This spec addresses the possibility of a race condition: if you change the
// component name faster than the component loads complete, then we need to
// ignore any load completions that don't correspond to the latest name.
// Otherwise it's inefficient if the loads complete in order (pointless extra
// binding), and broken if they complete out of order (wrong final result).
// Set up a mock module loader, so we can control asynchronous load completion
restoreAfter(cleanups, window as any, 'require')
const requireCallbacks = {}
window.require = function (moduleNames, callback) {
expect(moduleNames.length).to.equal(1) // In this scenario, it always will be
expect(moduleNames[0] in requireCallbacks).to.equal(false) // In this scenario, we only require each module once
requireCallbacks[moduleNames[0]] = callback
}
// Define four separate components so we can switch between them
const constructorCallLog = new Array()
function testViewModel1(params) {
constructorCallLog.push([1, params])
}
function testViewModel2(params) {
constructorCallLog.push([2, params])
}
function testViewModel3(params) {
constructorCallLog.push([3, params])
}
function testViewModel4(params) {
constructorCallLog.push([4, params])
}
testViewModel3.prototype.dispose = function () {
this.wasDisposed = true
}
components.register('component-1', {
viewModel: { require: 'module-1' },
template: '<div>Component 1 template</div>'
})
components.register('component-2', {
viewModel: { require: 'module-2' },
template: '<div>Component 2 template</div>'
})
components.register('component-3', {
viewModel: { require: 'module-3' },
template: '<div>Component 3 template</div>'
})
components.register('component-4', {
viewModel: { require: 'module-4' },
template: '<div>Component 4 template</div>'
})
cleanups.push(() => {
for (let i = 0; i < 4; i++) {
components.unregister('component-' + i)
}
})
// Start by requesting component 1
testComponentBindingValue.name = observable('component-1')
applyBindings(outerViewModel, testNode)
// Even if we wait a while, it's not yet loaded, because we're still waiting for the module
clock.tick(10)
expect(constructorCallLog.length).to.equal(0)
expect(testNode.firstChild).to.not.equal(null)
expect(testNode.firstChild?.childNodes.length).to.equal(0)
// In the meantime, switch to requesting component 2 and then 3
testComponentBindingValue.name('component-2')
clock.tick(1)
testComponentBindingValue.name('component-3')
expect(constructorCallLog.length).to.equal(0)
// Now if component 1 finishes loading, it's irrelevant, so nothing happens
requireCallbacks['module-1'](testViewModel1)
clock.tick(1) // ... even if we wait a bit longer
expect(constructorCallLog.length).to.equal(0)
expect(testNode.firstChild?.childNodes.length).to.equal(0)
// Now if component 3 finishes loading, it's the current one, so we instantiate and bind to it.
// Notice this happens synchronously (at least, relative to the time now), because the completion
// is already asynchronous relative to when it began.
requireCallbacks['module-3'](testViewModel3)
expect(constructorCallLog).to.deep.equal([[3, testComponentParams]])
expectContainText(testNode, 'Component 3 template')
const viewModelInstance = dataFor(testNode.firstChild?.firstChild as HTMLElement)
expect(viewModelInstance instanceof testViewModel3).to.equal(true)
expect(viewModelInstance.wasDisposed).to.not.equal(true)
// Now if component 2 finishes loading, it's irrelevant, so nothing happens.
// In particular, the viewmodel isn't disposed.
requireCallbacks['module-2'](testViewModel2)
clock.tick(1) // ... even if we wait a bit longer
expect(constructorCallLog.length).to.equal(1)
expectContainText(testNode, 'Component 3 template')
expect(viewModelInstance.wasDisposed).to.not.equal(true)
// However, if we now switch to component 2, the old viewmodel is disposed,
// and the new component is used without any further module load calls.
testComponentBindingValue.name('component-2')
clock.tick(1)
expect(constructorCallLog.length).to.equal(2)
expectContainText(testNode, 'Component 2 template')
expect(viewModelInstance.wasDisposed).to.equal(true)
// Show also that we won't leak memory by applying bindings to nodes
// after they were disposed (e.g., because they were removed from the document)
testComponentBindingValue.name('component-4')
clock.tick(1)
cleanNode(testNode.firstChild!) // Dispose the node before the module loading completes
requireCallbacks['module-4'](testViewModel4)
expect(constructorCallLog.length).to.equal(2) // No extra constructor calls
expectContainText(testNode, 'Component 2 template') // No attempt to modify the DOM
})
it('Supports virtual elements', function () {
testNode.innerHTML = 'Hello! <!-- ko component: testComponentBindingValue --> <!-- /ko --> Goodbye.'
components.register(testComponentName, { template: 'Your param is <span data-bind="text: someData"> </span>' })
testComponentParams.someData = observable(123)
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode, 'Hello! Your param is 123 Goodbye.')
testComponentParams.someData(456)
expectContainText(testNode, 'Hello! Your param is 456 Goodbye.')
})
it('Should call a childrenComplete callback function', function () {
testNode.innerHTML = '<div data-bind="component: testComponentBindingValue, childrenComplete: callback"></div>'
components.register(testComponentName, { template: '<div data-bind="text: myvalue"></div>' })
testComponentParams.myvalue = 'some parameter value'
let callbacks = 0
outerViewModel.callback = function (nodes, data) {
expect(nodes.length).to.deep.equal(1)
expect(nodes[0]).to.deep.equal(testNode.children[0].children[0])
expect(data).to.deep.equal(testComponentParams)
callbacks++
}
applyBindings(outerViewModel, testNode)
expect(callbacks).to.deep.equal(0)
clock.tick(1)
expectContainHtml(testNode.children[0], '<div data-bind="text: myvalue">some parameter value</div>')
expect(callbacks).to.deep.equal(1)
})
describe('Component `bindingHandlers`', function () {
it('overloads existing and provides new bindings', function () {
const calls = new Array()
testNode.innerHTML = `<with-my-bindings></with-my-bindings>`
class ViewModel {
getBindingHandler(bindingKey) {
return { text: () => calls.push('text'), text2: () => calls.push('text2') }[bindingKey]
}
}
const template = `
<span data-bind='text: "123"'></span>
<span data-bind='text2: "123"'></span>`
components.register('with-my-bindings', { viewModel: ViewModel, template, synchronous: true })
applyBindings({}, testNode)
expect(calls).to.deep.equal(['text', 'text2'])
})
})
describe('Does not automatically subscribe to any observables you evaluate during createViewModel or a viewmodel constructor', function () {
// This clarifies that, if a developer wants to react when some observable parameter
// changes, then it's their responsibility to subscribe to it or use a computed.
// We don't rebuild the component just because you evaluated an observable from inside
// your viewmodel constructor, just like we don't if you evaluate one elsewhere
// in the viewmodel code.
it('when loaded asynchronously', function () {
components.register(testComponentName, {
viewModel: {
createViewModel: function (params /*, componentInfo */) {
return { someData: params.someData() }
}
},
template: '<div data-bind="text: someData"></div>'
})
// Bind an instance
testComponentParams.someData = observable('First')
applyBindings(outerViewModel, testNode)
clock.tick(1)
expectContainText(testNode, 'First')
expect(testComponentParams.someData.getSubscriptionsCount()).to.equal(0)
// See that changing the observable will have no effect
testComponentParams.someData('Second')
clock.tick(1)
expectContainText(testNode, 'First')
})