Skip to content

Commit eb94e4f

Browse files
authored
[web] Fix disposal of browser history on hot restart (flutter#28805)
1 parent a3911f2 commit eb94e4f

File tree

3 files changed

+147
-6
lines changed

3 files changed

+147
-6
lines changed

lib/web_ui/lib/src/engine/navigation/history.dart

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:html' as html;
66

7+
import 'package:meta/meta.dart';
78
import 'package:ui/ui.dart' as ui;
89

910
import '../platform_dispatcher.dart';
@@ -47,6 +48,7 @@ abstract class BrowserHistory {
4748
/// The strategy to interact with html browser history.
4849
UrlStrategy? get urlStrategy;
4950

51+
bool _isTornDown = false;
5052
bool _isDisposed = false;
5153

5254
void _setupStrategy(UrlStrategy strategy) {
@@ -55,6 +57,20 @@ abstract class BrowserHistory {
5557
);
5658
}
5759

60+
/// Release any resources held by this [BrowserHistory] instance.
61+
///
62+
/// This method has no effect on the browser history entries. Use [tearDown]
63+
/// instead to revert this instance's modifications to browser history
64+
/// entries.
65+
@mustCallSuper
66+
void dispose() {
67+
if (_isDisposed || urlStrategy == null) {
68+
return;
69+
}
70+
_isDisposed = true;
71+
_unsubscribe();
72+
}
73+
5874
/// Exit this application and return to the previous page.
5975
Future<void> exit() async {
6076
if (urlStrategy != null) {
@@ -196,11 +212,12 @@ class MultiEntriesBrowserHistory extends BrowserHistory {
196212

197213
@override
198214
Future<void> tearDown() async {
199-
if (_isDisposed || urlStrategy == null) {
215+
dispose();
216+
217+
if (_isTornDown || urlStrategy == null) {
200218
return;
201219
}
202-
_isDisposed = true;
203-
_unsubscribe();
220+
_isTornDown = true;
204221

205222
// Restores the html browser history.
206223
assert(_hasSerialCount(currentState));
@@ -367,11 +384,12 @@ class SingleEntryBrowserHistory extends BrowserHistory {
367384

368385
@override
369386
Future<void> tearDown() async {
370-
if (_isDisposed || urlStrategy == null) {
387+
dispose();
388+
389+
if (_isTornDown || urlStrategy == null) {
371390
return;
372391
}
373-
_isDisposed = true;
374-
_unsubscribe();
392+
_isTornDown = true;
375393

376394
// We need to remove the flutter entry that we pushed in setup.
377395
await urlStrategy!.go(-1);

lib/web_ui/lib/src/engine/window.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:js/js.dart';
1313
import 'package:meta/meta.dart';
1414
import 'package:ui/ui.dart' as ui;
1515

16+
import '../engine.dart' show registerHotRestartListener;
1617
import 'browser_detection.dart';
1718
import 'navigation/history.dart';
1819
import 'navigation/js_url_strategy.dart';
@@ -51,6 +52,9 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow {
5152
if (_isUrlStrategySet) {
5253
_browserHistory = createHistoryForExistingState(_customUrlStrategy);
5354
}
55+
registerHotRestartListener(() {
56+
_browserHistory?.dispose();
57+
});
5458
}
5559

5660
final Object _windowId;

lib/web_ui/test/engine/history_test.dart

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import 'dart:async';
99
import 'dart:html' as html;
1010

11+
import 'package:quiver/testing/async.dart';
1112
import 'package:test/bootstrap/browser.dart';
1213
import 'package:test/test.dart';
1314
import 'package:ui/src/engine.dart' show window;
@@ -121,6 +122,68 @@ void testMain() {
121122
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50836
122123
skip: browserEngine == BrowserEngine.edge);
123124

125+
test('disposes of its listener without touching history', () async {
126+
const String unwrappedOriginState = 'initial state';
127+
final Map<String, dynamic> wrappedOriginState = _wrapOriginState(unwrappedOriginState);
128+
129+
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
130+
const TestHistoryEntry(unwrappedOriginState, null, '/initial'),
131+
);
132+
expect(strategy.listeners, isEmpty);
133+
134+
await window.debugInitializeHistory(strategy, useSingle: true);
135+
136+
137+
// There should be one `popstate` listener and two history entries.
138+
expect(strategy.listeners, hasLength(1));
139+
expect(strategy.history, hasLength(2));
140+
expect(strategy.history[0].state, wrappedOriginState);
141+
expect(strategy.history[0].url, '/initial');
142+
expect(strategy.history[1].state, flutterState);
143+
expect(strategy.history[1].url, '/initial');
144+
145+
FakeAsync().run((FakeAsync fakeAsync) {
146+
window.browserHistory.dispose();
147+
// The `TestUrlStrategy` implementation uses microtasks to schedule the
148+
// removal of event listeners.
149+
fakeAsync.flushMicrotasks();
150+
});
151+
152+
// After disposing, there should no listeners, and the history entries
153+
// remain unaffected.
154+
expect(strategy.listeners, isEmpty);
155+
expect(strategy.history, hasLength(2));
156+
expect(strategy.history[0].state, wrappedOriginState);
157+
expect(strategy.history[0].url, '/initial');
158+
expect(strategy.history[1].state, flutterState);
159+
expect(strategy.history[1].url, '/initial');
160+
161+
// An extra call to dispose should be safe.
162+
FakeAsync().run((FakeAsync fakeAsync) {
163+
expect(() => window.browserHistory.dispose(), returnsNormally);
164+
fakeAsync.flushMicrotasks();
165+
});
166+
167+
// Same expectations should remain true after the second dispose.
168+
expect(strategy.listeners, isEmpty);
169+
expect(strategy.history, hasLength(2));
170+
expect(strategy.history[0].state, wrappedOriginState);
171+
expect(strategy.history[0].url, '/initial');
172+
expect(strategy.history[1].state, flutterState);
173+
expect(strategy.history[1].url, '/initial');
174+
175+
// Can still teardown after being disposed.
176+
await window.browserHistory.tearDown();
177+
expect(strategy.history, hasLength(2));
178+
expect(strategy.currentEntry.state, unwrappedOriginState);
179+
expect(strategy.currentEntry.url, '/initial');
180+
});
181+
182+
test('disposes gracefully when url strategy is null', () async {
183+
await window.debugInitializeHistory(null, useSingle: true);
184+
expect(() => window.browserHistory.dispose(), returnsNormally);
185+
});
186+
124187
test('browser back button pops routes correctly', () async {
125188
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
126189
const TestHistoryEntry(null, null, '/home'),
@@ -326,6 +389,62 @@ void testMain() {
326389
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50836
327390
skip: browserEngine == BrowserEngine.edge);
328391

392+
test('disposes of its listener without touching history', () async {
393+
const String untaggedState = 'initial state';
394+
final Map<String, dynamic> taggedState = _tagStateWithSerialCount(untaggedState, 0);
395+
396+
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
397+
const TestHistoryEntry(untaggedState, null, '/initial'),
398+
);
399+
expect(strategy.listeners, isEmpty);
400+
401+
await window.debugInitializeHistory(strategy, useSingle: false);
402+
403+
404+
// There should be one `popstate` listener and one history entry.
405+
expect(strategy.listeners, hasLength(1));
406+
expect(strategy.history, hasLength(1));
407+
expect(strategy.history.single.state, taggedState);
408+
expect(strategy.history.single.url, '/initial');
409+
410+
FakeAsync().run((FakeAsync fakeAsync) {
411+
window.browserHistory.dispose();
412+
// The `TestUrlStrategy` implementation uses microtasks to schedule the
413+
// removal of event listeners.
414+
fakeAsync.flushMicrotasks();
415+
});
416+
417+
// After disposing, there should no listeners, and the history entries
418+
// remain unaffected.
419+
expect(strategy.listeners, isEmpty);
420+
expect(strategy.history, hasLength(1));
421+
expect(strategy.history.single.state, taggedState);
422+
expect(strategy.history.single.url, '/initial');
423+
424+
// An extra call to dispose should be safe.
425+
FakeAsync().run((FakeAsync fakeAsync) {
426+
expect(() => window.browserHistory.dispose(), returnsNormally);
427+
fakeAsync.flushMicrotasks();
428+
});
429+
430+
// Same expectations should remain true after the second dispose.
431+
expect(strategy.listeners, isEmpty);
432+
expect(strategy.history, hasLength(1));
433+
expect(strategy.history.single.state, taggedState);
434+
expect(strategy.history.single.url, '/initial');
435+
436+
// Can still teardown after being disposed.
437+
await window.browserHistory.tearDown();
438+
expect(strategy.history, hasLength(1));
439+
expect(strategy.history.single.state, untaggedState);
440+
expect(strategy.history.single.url, '/initial');
441+
});
442+
443+
test('disposes gracefully when url strategy is null', () async {
444+
await window.debugInitializeHistory(null, useSingle: false);
445+
expect(() => window.browserHistory.dispose(), returnsNormally);
446+
});
447+
329448
test('browser back button push route information correctly', () async {
330449
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
331450
const TestHistoryEntry('initial state', null, '/home'),

0 commit comments

Comments
 (0)