Skip to content

Commit 908f659

Browse files
author
Anatoly Ostrovsky
committed
Add proxy updates support to collection
1 parent 821d73a commit 908f659

3 files changed

Lines changed: 175 additions & 36 deletions

File tree

src/binding.spec.js

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,30 @@ describe("binding", () => {
3939

4040
afterEach(() => dealoc(document.getElementById("app")));
4141

42-
it("BindUpdate", () => {
42+
it("should initialize a scope property with ng-init on a self-closing element", () => {
4343
$compile('<div ng-init="a=123"/>')($rootScope);
4444
expect($rootScope.a).toBe(123);
4545
});
4646

47-
it("ExecuteInitialization", () => {
47+
it("should execute ng-init on a standard opening element", () => {
4848
$compile('<div ng-init="a=123">')($rootScope);
4949
expect($rootScope.a).toBe(123);
5050
});
5151

52-
it("ExecuteInitializationStatements", () => {
52+
it("should execute multiple statements declared in ng-init", () => {
5353
$compile('<div ng-init="a=123;b=345">')($rootScope);
5454
expect($rootScope.a).toBe(123);
5555
expect($rootScope.b).toBe(345);
5656
});
5757

58-
it("ApplyTextBindings", async () => {
58+
it("should apply ng-bind text updates when the model changes", async () => {
5959
element = $compile('<div ng-bind="model.a">x</div>')($rootScope);
6060
$rootScope.model = { a: 123 };
6161
await wait();
6262
expect(element.textContent).toBe("123");
6363
});
6464

65-
it("InputTypeButtonActionExecutesInScope", () => {
65+
it("should execute an ng-click handler on an input button within the current scope", () => {
6666
let savedCalled = false;
6767
element = $compile(
6868
'<input type="button" ng-click="person.save()" value="Apply">',
@@ -81,7 +81,7 @@ describe("binding", () => {
8181
expect(savedCalled).toBe(true);
8282
});
8383

84-
it("InputTypeButtonActionExecutesInScope2", () => {
84+
it("should execute an ng-click handler on an input image element", () => {
8585
let log = "";
8686
element = $compile('<input type="image" ng-click="action()">')($rootScope);
8787
$rootScope.action = function () {
@@ -98,7 +98,7 @@ describe("binding", () => {
9898
expect(log).toEqual("click;");
9999
});
100100

101-
it("ButtonElementActionExecutesInScope", () => {
101+
it("should execute an ng-click handler on a button element within the current scope", () => {
102102
let savedCalled = false;
103103
element = $compile('<button ng-click="person.save()">Apply</button>')(
104104
$rootScope,
@@ -117,7 +117,7 @@ describe("binding", () => {
117117
expect(savedCalled).toBe(true);
118118
});
119119

120-
it("RepeaterUpdateBindings", async () => {
120+
it("should update ng-repeat bindings when items are added and removed", async () => {
121121
let elem = createElementFromHTML(
122122
"<ul>" + '<li ng-repeat="item in items" ng-bind="item.a"></li>' + "</ul>",
123123
);
@@ -156,7 +156,79 @@ describe("binding", () => {
156156
elem.remove();
157157
});
158158

159-
it("RepeaterContentDoesNotBind", async () => {
159+
it("should update ng-repeat when proxied collection items are swapped directly", async () => {
160+
let elem = createElementFromHTML(
161+
"<ul>" + '<li ng-repeat="item in items" ng-bind="item.a"></li>' + "</ul>",
162+
);
163+
document.getElementById("app").insertAdjacentElement("afterend", elem);
164+
$injector = window.angular.bootstrap(elem, ["myModule"]);
165+
$rootScope = $injector.get("$rootScope");
166+
167+
$rootScope.items = [{ a: "A" }, { a: "B" }];
168+
await wait();
169+
170+
let lis = elem.querySelectorAll("li");
171+
const firstRow = lis[0];
172+
const secondRow = lis[1];
173+
174+
firstRow.setAttribute("mark", "first");
175+
secondRow.setAttribute("mark", "second");
176+
177+
const tmp = $rootScope.items[0];
178+
179+
$rootScope.items[0] = $rootScope.items[1];
180+
$rootScope.items[1] = tmp;
181+
await wait();
182+
183+
lis = elem.querySelectorAll("li");
184+
expect(elem.outerHTML).toBe(
185+
"<ul><!---->" +
186+
'<li ng-repeat="item in items" ng-bind="item.a" mark="second">B</li>' +
187+
'<li ng-repeat="item in items" ng-bind="item.a" mark="first">A</li>' +
188+
"</ul>",
189+
);
190+
expect(lis[0]).toBe(secondRow);
191+
expect(lis[1]).toBe(firstRow);
192+
elem.remove();
193+
});
194+
195+
it("should keep nested child scopes alive when proxied collection items are swapped directly", async () => {
196+
let elem = createElementFromHTML(
197+
"<ul>" + '<li ng-repeat="item in items" ng-bind="item.a"></li>' + "</ul>",
198+
);
199+
document.getElementById("app").insertAdjacentElement("afterend", elem);
200+
$injector = window.angular.bootstrap(elem, ["myModule"]);
201+
$rootScope = $injector.get("$rootScope");
202+
203+
const nestedA = $rootScope.$new();
204+
const nestedB = $rootScope.$new();
205+
const destroyA = jasmine.createSpy("nestedA destroy");
206+
const destroyB = jasmine.createSpy("nestedB destroy");
207+
208+
nestedA.$on("$destroy", destroyA);
209+
nestedB.$on("$destroy", destroyB);
210+
211+
$rootScope.items = [
212+
{ a: "A", nested: nestedA },
213+
{ a: "B", nested: nestedB },
214+
];
215+
await wait();
216+
217+
const tmp = $rootScope.items[0];
218+
219+
$rootScope.items[0] = $rootScope.items[1];
220+
$rootScope.items[1] = tmp;
221+
await wait();
222+
223+
expect(elem.textContent).toBe("BA");
224+
expect(destroyA).not.toHaveBeenCalled();
225+
expect(destroyB).not.toHaveBeenCalled();
226+
expect(nestedA.$handler._destroyed).toBeFalse();
227+
expect(nestedB.$handler._destroyed).toBeFalse();
228+
elem.remove();
229+
});
230+
231+
it("should bind repeated content when the repeated collection becomes available", async () => {
160232
element = $compile(
161233
"<ul>" +
162234
'<LI ng-repeat="item in model.items"><span ng-bind="item.a"></span></li>' +
@@ -172,14 +244,14 @@ describe("binding", () => {
172244
);
173245
});
174246

175-
it("DoNotOverwriteCustomAction", async function () {
247+
it("should preserve a custom action attribute on a submit input", async function () {
176248
const html = await this.compileToHtml(
177249
'<input type="submit" value="Save" action="foo();">',
178250
);
179251
expect(html.indexOf('action="foo();"')).toBeGreaterThan(0);
180252
});
181253

182-
it("ItShouldRemoveExtraChildrenWhenIteratingOverHash", async () => {
254+
it("should remove extra repeated children when iterating over an object that shrinks", async () => {
183255
element = $compile('<div><div ng-repeat="i in items">{{i}}</div></div>')(
184256
$rootScope,
185257
);
@@ -197,7 +269,7 @@ describe("binding", () => {
197269
expect(element.textContent).toEqual("");
198270
});
199271

200-
it("IfAttrBindingThrowsErrorDecorateTheAttribute", async () => {
272+
it("should report interpolation errors from attribute bindings and recover afterward", async () => {
201273
$compile(
202274
'<div attr="before {{error.throw()}} after"></div>',
203275
null,
@@ -222,7 +294,7 @@ describe("binding", () => {
222294
expect(errors.length).toMatch("0");
223295
});
224296

225-
it("NestedRepeater", async () => {
297+
it("should render nested ng-repeat blocks for nested collections", async () => {
226298
element = $compile(
227299
"<div>" +
228300
'<div ng-repeat="m in model" name="{{m.name}}">' +
@@ -252,7 +324,7 @@ describe("binding", () => {
252324
);
253325
});
254326

255-
it("HideBindingExpression", async () => {
327+
it("should toggle ng-hide from an expression result", async () => {
256328
element = $compile('<div ng-hide="hidden === 3"/>')($rootScope);
257329

258330
$rootScope.hidden = 3;
@@ -265,7 +337,7 @@ describe("binding", () => {
265337
expect(element.classList.contains("ng-hide")).toBe(false);
266338
});
267339

268-
it("HideBinding", async () => {
340+
it("should apply ng-hide according to truthy and falsy values", async () => {
269341
element = $compile('<div ng-hide="hidden"/>')($rootScope);
270342

271343
$rootScope.hidden = "true";
@@ -289,7 +361,7 @@ describe("binding", () => {
289361
expect(element.classList.contains("ng-hide")).toBeFalse();
290362
});
291363

292-
it("ShowBinding", async () => {
364+
it("should apply ng-show according to truthy and falsy values", async () => {
293365
element = $compile('<div ng-show="show"/>')($rootScope);
294366

295367
$rootScope.show = "true";
@@ -309,7 +381,7 @@ describe("binding", () => {
309381
expect(element.classList.contains("ng-hide")).toBeTrue();
310382
});
311383

312-
it("BindClass", async () => {
384+
it("should apply ng-class values from strings and arrays", async () => {
313385
element = $compile('<div ng-class="clazz"/>')($rootScope);
314386

315387
$rootScope.clazz = "testClass";
@@ -323,7 +395,7 @@ describe("binding", () => {
323395
expect(element.classList.contains("b")).toBeTrue();
324396
});
325397

326-
it("BindClassEvenOdd", async () => {
398+
it("should apply ng-class-even and ng-class-odd inside ng-repeat", async () => {
327399
element = $compile(
328400
"<div>" +
329401
'<div ng-repeat="i in [0,1]" ng-class-even="\'e\'" ng-class-odd="\'o\'"></div>' +
@@ -338,7 +410,7 @@ describe("binding", () => {
338410
expect(d2.classList.contains("e")).toBeTruthy();
339411
});
340412

341-
it("BindStyle", async () => {
413+
it("should apply ng-style values from the evaluated style object", async () => {
342414
element = $compile('<div ng-style="style"/>')($rootScope);
343415

344416
$rootScope.$eval('style={height: "10px"}');
@@ -350,7 +422,7 @@ describe("binding", () => {
350422
await wait();
351423
});
352424

353-
it("ActionOnAHrefThrowsError", async () => {
425+
it("should report errors thrown from an ng-click handler on a link", async () => {
354426
const input = $compile('<a ng-click="action()">Add Phone</a>')($rootScope);
355427
$rootScope.action = function () {
356428
throw new Error("MyError");
@@ -367,7 +439,7 @@ describe("binding", () => {
367439
expect(errors[0]).toMatch(/MyError/);
368440
});
369441

370-
it("ShouldIgnoreVbNonBindable", async () => {
442+
it("should ignore bindings inside ng-non-bindable sections", async () => {
371443
element = $compile(
372444
"<div>{{a}}" +
373445
"<div ng-non-bindable>{{a}}</div>" +
@@ -380,15 +452,15 @@ describe("binding", () => {
380452
expect(element.textContent).toBe("123{{a}}{{b}}{{c}}");
381453
});
382454

383-
it("ShouldTemplateBindPreElements", async () => {
455+
it("should preserve and bind interpolation inside preformatted elements", async () => {
384456
element = $compile("<pre>Hello {{name}}!</pre>")($rootScope);
385457
$rootScope.name = "World";
386458
await wait();
387459

388460
expect(element.outerHTML).toBe(`<pre>Hello World!</pre>`);
389461
});
390462

391-
it("FillInOptionValueWhenMissing", async () => {
463+
it("should fill in option values when an option value attribute is missing", async () => {
392464
element = $compile(
393465
'<select ng-model="foo">' +
394466
'<option selected="true">{{a}}</option>' +
@@ -414,7 +486,7 @@ describe("binding", () => {
414486
expect(optionC.textContent).toEqual("C");
415487
});
416488

417-
it("ItShouldSelectTheCorrectRadioBox", async () => {
489+
it("should keep radio inputs synchronized with the bound model", async () => {
418490
const ELEMENT = document.getElementById("app");
419491
ELEMENT.innerHTML =
420492
"<div>" +
@@ -439,7 +511,7 @@ describe("binding", () => {
439511
expect($rootScope.sex).toBe("male");
440512
});
441513

442-
it("ItShouldRepeatOnHashes", async () => {
514+
it("should repeat over object literals with ng-repeat", async () => {
443515
element = $compile(
444516
"<ul>" +
445517
'<li ng-repeat="(k,v) in {a:0,b:1}" ng-bind="k + v"></li>' +
@@ -454,7 +526,7 @@ describe("binding", () => {
454526
);
455527
});
456528

457-
it("ItShouldFireChangeListenersBeforeUpdate", async () => {
529+
it("should fire change listeners before the bound DOM is updated", async () => {
458530
element = $compile('<div ng-bind="name"></div>')($rootScope);
459531
$rootScope.name = "";
460532
$rootScope.$watch("watched", () => {
@@ -466,7 +538,7 @@ describe("binding", () => {
466538
expect(element.outerHTML).toBe('<div ng-bind="name">123</div>');
467539
});
468540

469-
it("ItShouldHandleMultilineBindings", async () => {
541+
it("should evaluate multiline interpolation expressions", async () => {
470542
element = $compile("<div>{{\n 1 \n + \n 2 \n}}</div>")($rootScope);
471543
await wait();
472544
expect(element.textContent).toBe("3");

src/core/scope/scope.spec.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3415,6 +3415,71 @@ describe("Scope", () => {
34153415
expect(scope.current.length).toBe(0);
34163416
});
34173417

3418+
describe("proxy rebind safety", () => {
3419+
it("should not destroy nested child scopes when rebinding one proxied object to another", () => {
3420+
const scope = createScope();
3421+
const nestedChild = scope.$new();
3422+
const onDestroy = jasmine.createSpy(
3423+
"nested child destroy on proxy rebind",
3424+
);
3425+
3426+
nestedChild.$on("$destroy", onDestroy);
3427+
3428+
scope.first = { nested: nestedChild };
3429+
scope.second = { nested: true };
3430+
3431+
const first = scope.first;
3432+
const second = scope.second;
3433+
3434+
scope.current = first;
3435+
scope.current = second;
3436+
3437+
expect(onDestroy).not.toHaveBeenCalled();
3438+
expect(nestedChild.$handler._destroyed).toBeFalse();
3439+
expect(scope._children.includes(nestedChild)).toBeTrue();
3440+
expect(first.nested).toBe(nestedChild);
3441+
expect(scope.current).toBe(second);
3442+
});
3443+
3444+
it("should not destroy nested child scopes when swapping proxied array items by index", () => {
3445+
const scope = createScope();
3446+
const nestedA = scope.$new();
3447+
const nestedB = scope.$new();
3448+
const destroyA = jasmine.createSpy(
3449+
"nestedA destroy on array proxy swap",
3450+
);
3451+
const destroyB = jasmine.createSpy(
3452+
"nestedB destroy on array proxy swap",
3453+
);
3454+
3455+
nestedA.$on("$destroy", destroyA);
3456+
nestedB.$on("$destroy", destroyB);
3457+
3458+
scope.items = [
3459+
{ label: "A", nested: nestedA },
3460+
{ label: "B", nested: nestedB },
3461+
];
3462+
3463+
const first = scope.items[0];
3464+
const second = scope.items[1];
3465+
const tmp = first;
3466+
3467+
scope.items[0] = second;
3468+
scope.items[1] = tmp;
3469+
3470+
expect(destroyA).not.toHaveBeenCalled();
3471+
expect(destroyB).not.toHaveBeenCalled();
3472+
expect(nestedA.$handler._destroyed).toBeFalse();
3473+
expect(nestedB.$handler._destroyed).toBeFalse();
3474+
expect(scope._children.includes(nestedA)).toBeTrue();
3475+
expect(scope._children.includes(nestedB)).toBeTrue();
3476+
expect(scope.items[0]).toBe(second);
3477+
expect(scope.items[1]).toBe(first);
3478+
expect(scope.items[0].nested).toBe(nestedB);
3479+
expect(scope.items[1].nested).toBe(nestedA);
3480+
});
3481+
});
3482+
34183483
// it("should clean up all watchers for child", () => {
34193484
// const scope = createScope();
34203485
// scope.$watch("a", () => { /* empty */ });

0 commit comments

Comments
 (0)