Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 054d40f

Browse files
committed
fix(form): prevent page reload when form destroyed
this fix ensures that we prevent the default action on form submission (full page reload) even in cases when the form is being destroyed as a result of the submit event handler (e.g. when route change is triggered). The fix is more complicated than I'd like it to be mainly because we need to ensure that we don't create circular references between js closures and dom elements via DOM event handlers that would then result in a memory leak. Also the differences between IE8, IE9 and normal browsers make testing this ugly. Closes #1238
1 parent 5cec324 commit 054d40f

File tree

2 files changed

+146
-33
lines changed

2 files changed

+146
-33
lines changed

src/ng/directive/form.js

+51-27
Original file line numberDiff line numberDiff line change
@@ -227,38 +227,62 @@ function FormController(element, attrs) {
227227
</doc:scenario>
228228
</doc:example>
229229
*/
230-
var formDirectiveDir = {
231-
name: 'form',
232-
restrict: 'E',
233-
controller: FormController,
234-
compile: function() {
235-
return {
236-
pre: function(scope, formElement, attr, controller) {
237-
if (!attr.action) {
238-
formElement.bind('submit', function(event) {
239-
event.preventDefault();
240-
});
241-
}
230+
var formDirectiveFactory = function(isNgForm) {
231+
return ['$timeout', function($timeout) {
232+
var formDirective = {
233+
name: 'form',
234+
restrict: 'E',
235+
controller: FormController,
236+
compile: function() {
237+
return {
238+
pre: function(scope, formElement, attr, controller) {
239+
if (!attr.action) {
240+
// we can't use jq events because if a form is destroyed during submission the default
241+
// action is not prevented. see #1238
242+
//
243+
// IE 9 is not affected because it doesn't fire a submit event and try to do a full
244+
// page reload if the form was destroyed by submission of the form via a click handler
245+
// on a button in the form. Looks like an IE9 specific bug.
246+
var preventDefaultListener = function(event) {
247+
event.preventDefault
248+
? event.preventDefault()
249+
: event.returnValue = false; // IE
250+
};
242251

243-
var parentFormCtrl = formElement.parent().controller('form'),
244-
alias = attr.name || attr.ngForm;
252+
addEventListenerFn(formElement[0], 'submit', preventDefaultListener);
253+
254+
// unregister the preventDefault listener so that we don't not leak memory but in a
255+
// way that will achieve the prevention of the default action.
256+
formElement.bind('$destroy', function() {
257+
$timeout(function() {
258+
removeEventListenerFn(formElement[0], 'submit', preventDefaultListener);
259+
}, 0, false);
260+
});
261+
}
262+
263+
var parentFormCtrl = formElement.parent().controller('form'),
264+
alias = attr.name || attr.ngForm;
245265

246-
if (alias) {
247-
scope[alias] = controller;
248-
}
249-
if (parentFormCtrl) {
250-
formElement.bind('$destroy', function() {
251-
parentFormCtrl.$removeControl(controller);
252266
if (alias) {
253-
scope[alias] = undefined;
267+
scope[alias] = controller;
254268
}
255-
extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards
256-
});
257-
}
269+
if (parentFormCtrl) {
270+
formElement.bind('$destroy', function() {
271+
parentFormCtrl.$removeControl(controller);
272+
if (alias) {
273+
scope[alias] = undefined;
274+
}
275+
extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards
276+
});
277+
}
278+
}
279+
};
258280
}
259281
};
260-
}
282+
283+
return isNgForm ? extend(copy(formDirective), {restrict: 'EAC'}) : formDirective;
284+
}];
261285
};
262286

263-
var formDirective = valueFn(formDirectiveDir);
264-
var ngFormDirective = valueFn(extend(copy(formDirectiveDir), {restrict: 'EAC'}));
287+
var formDirective = formDirectiveFactory();
288+
var ngFormDirective = formDirectiveFactory(true);

test/ng/directive/formSpec.js

+95-6
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,28 @@ describe('form', function() {
129129

130130
it('should prevent form submission', function() {
131131
var nextTurn = false,
132+
submitted = false,
132133
reloadPrevented;
133134

134-
doc = jqLite('<form><input type="submit" value="submit" ></form>');
135+
doc = jqLite('<form ng-submit="submitMe()">' +
136+
'<input type="submit" value="submit">' +
137+
'</form>');
138+
139+
var assertPreventDefaultListener = function(e) {
140+
reloadPrevented = e.defaultPrevented || (e.returnValue === false);
141+
};
142+
143+
// native dom event listeners in IE8 fire in LIFO order so we have to register them
144+
// there in different order than in other browsers
145+
if (msie==8) addEventListenerFn(doc[0], 'submit', assertPreventDefaultListener);
146+
135147
$compile(doc)(scope);
136148

137-
doc.bind('submit', function(e) {
138-
reloadPrevented = e.defaultPrevented;
139-
});
149+
scope.submitMe = function() {
150+
submitted = true;
151+
}
152+
153+
if (msie!=8) addEventListenerFn(doc[0], 'submit', assertPreventDefaultListener);
140154

141155
browserTrigger(doc.find('input'));
142156

@@ -147,17 +161,92 @@ describe('form', function() {
147161

148162
runs(function() {
149163
expect(reloadPrevented).toBe(true);
164+
expect(submitted).toBe(true);
165+
166+
// prevent mem leak in test
167+
removeEventListenerFn(doc[0], 'submit', assertPreventDefaultListener);
150168
});
151169
});
152170

153171

154-
it('should not prevent form submission if action attribute present', function() {
172+
it('should prevent the default when the form is destroyed by a submission via a click event',
173+
inject(function($timeout) {
174+
doc = jqLite('<div>' +
175+
'<form ng-submit="submitMe()">' +
176+
'<button ng-click="destroy()"></button>' +
177+
'</form>' +
178+
'</div>');
179+
180+
var form = doc.find('form'),
181+
destroyed = false,
182+
nextTurn = false,
183+
submitted = false,
184+
reloadPrevented;
185+
186+
scope.destroy = function() {
187+
// yes, I know, scope methods should not do direct DOM manipulation, but I wanted to keep
188+
// this test small. Imagine that the destroy action will cause a model change (e.g.
189+
// $location change) that will cause some directive to destroy the dom (e.g. ngView+$route)
190+
doc.html('');
191+
destroyed = true;
192+
}
193+
194+
scope.submitMe = function() {
195+
submitted = true;
196+
}
197+
198+
var assertPreventDefaultListener = function(e) {
199+
reloadPrevented = e.defaultPrevented || (e.returnValue === false);
200+
};
201+
202+
// native dom event listeners in IE8 fire in LIFO order so we have to register them
203+
// there in different order than in other browsers
204+
if (msie == 8) addEventListenerFn(form[0], 'submit', assertPreventDefaultListener);
205+
206+
$compile(doc)(scope);
207+
208+
if (msie != 8) addEventListenerFn(form[0], 'submit', assertPreventDefaultListener);
209+
210+
browserTrigger(doc.find('button'), 'click');
211+
212+
// let the browser process all events (and potentially reload the page)
213+
setTimeout(function() { nextTurn = true;}, 100);
214+
215+
waitsFor(function() { return nextTurn; });
216+
217+
218+
// I can't get IE8 to automatically trigger submit in this test, in production it does it
219+
// properly
220+
if (msie == 8) browserTrigger(form, 'submit');
221+
222+
runs(function() {
223+
expect(doc.html()).toBe('');
224+
expect(destroyed).toBe(true);
225+
expect(submitted).toBe(false); // this is known corner-case that is not currently handled
226+
// the issue is that the submit listener is destroyed before
227+
// the event propagates there. we can fix this if we see
228+
// the issue in the wild, I'm not going to bother to do it
229+
// now. (i)
230+
231+
// IE9 is special and it doesn't fire submit event when form was destroyed
232+
if (msie != 9) {
233+
expect(reloadPrevented).toBe(true);
234+
$timeout.flush();
235+
}
236+
237+
// prevent mem leak in test
238+
removeEventListenerFn(form[0], 'submit', assertPreventDefaultListener);
239+
});
240+
}));
241+
242+
243+
it('should NOT prevent form submission if action attribute present', function() {
155244
var callback = jasmine.createSpy('submit').andCallFake(function(event) {
156245
expect(event.isDefaultPrevented()).toBe(false);
157246
event.preventDefault();
158247
});
159248

160-
doc = $compile('<form name="x" action="some.py" />')(scope);
249+
doc = $compile('<form action="some.py"></form>')(scope);
161250
doc.bind('submit', callback);
162251

163252
browserTrigger(doc, 'submit');

0 commit comments

Comments
 (0)