Skip to content

Commit e5cc738

Browse files
committed
feat(dart_frog_cli): improve hot reload error reporting/recovery
1 parent fbf63c9 commit e5cc738

File tree

4 files changed

+110
-15
lines changed

4 files changed

+110
-15
lines changed

packages/dart_frog_cli/lib/src/commands/dev/dev.dart

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import 'dart:convert';
22
import 'dart:io' as io;
33

4+
import 'package:collection/collection.dart';
45
import 'package:dart_frog_cli/src/command.dart';
56
import 'package:dart_frog_cli/src/commands/commands.dart';
67
import 'package:dart_frog_cli/src/commands/dev/templates/dart_frog_dev_server_bundle.dart';
78
import 'package:mason/mason.dart';
9+
import 'package:meta/meta.dart';
810
import 'package:path/path.dart' as path;
11+
import 'package:stream_transform/stream_transform.dart';
912
import 'package:watcher/watcher.dart';
1013

1114
/// Typedef for [io.Process.start].
@@ -85,6 +88,7 @@ class DevCommand extends DartFrogCommand {
8588

8689
@override
8790
Future<int> run() async {
91+
var reloading = false;
8892
var hotReloadEnabled = false;
8993
final port = io.Platform.environment['PORT'] ?? results['port'] as String;
9094
final generator = await _generator(dartFrogDevServerBundle);
@@ -104,6 +108,12 @@ class DevCommand extends DartFrogCommand {
104108
);
105109
}
106110

111+
Future<void> reload() async {
112+
reloading = true;
113+
await codegen();
114+
reloading = false;
115+
}
116+
107117
Future<void> serve() async {
108118
final process = await _startProcess(
109119
'dart',
@@ -119,7 +129,12 @@ class DevCommand extends DartFrogCommand {
119129
var hasError = false;
120130
process.stderr.listen((_) async {
121131
hasError = true;
122-
logger.err(utf8.decode(_));
132+
if (reloading) return;
133+
134+
final message = utf8.decode(_).trim();
135+
if (message.isEmpty) return;
136+
137+
logger.err(message);
123138

124139
if (!hotReloadEnabled) {
125140
await _killProcess(process);
@@ -132,8 +147,10 @@ class DevCommand extends DartFrogCommand {
132147
process.stdout.listen((_) {
133148
final message = utf8.decode(_).trim();
134149
if (message.contains('[hotreload]')) hotReloadEnabled = true;
135-
if (!hasError) _generatorTarget.cacheLatestSnapshot();
136150
if (message.isNotEmpty) logger.info(message);
151+
final shouldCacheSnapshot =
152+
hotReloadEnabled && !hasError && message.isNotEmpty;
153+
if (shouldCacheSnapshot) _generatorTarget.cacheLatestSnapshot();
137154
hasError = false;
138155
});
139156
}
@@ -146,14 +163,16 @@ class DevCommand extends DartFrogCommand {
146163
final public = path.join(cwd.path, 'public');
147164
final routes = path.join(cwd.path, 'routes');
148165

149-
bool shouldRunCodegen(WatchEvent event) {
166+
bool shouldReload(WatchEvent event) {
150167
return path.isWithin(routes, event.path) ||
151168
path.isWithin(public, event.path);
152169
}
153170

154171
final watcher = _directoryWatcher(path.join(cwd.path));
155-
final subscription =
156-
watcher.events.where(shouldRunCodegen).listen((_) => codegen());
172+
final subscription = watcher.events
173+
.where(shouldReload)
174+
.debounce(Duration.zero)
175+
.listen((_) => reload());
157176

158177
await subscription.asFuture<void>();
159178
await subscription.cancel();
@@ -175,6 +194,7 @@ class DevCommand extends DartFrogCommand {
175194
/// {@template cached_file}
176195
/// A cached file which consists of the file path and contents.
177196
/// {@endtemplate}
197+
@immutable
178198
class CachedFile {
179199
/// {@macro cached_file}
180200
const CachedFile({required this.path, required this.contents});
@@ -184,6 +204,19 @@ class CachedFile {
184204

185205
/// The contents of the generated file.s
186206
final List<int> contents;
207+
208+
@override
209+
bool operator ==(Object other) {
210+
if (identical(this, other)) return true;
211+
final listEquals = const DeepCollectionEquality().equals;
212+
213+
return other is CachedFile &&
214+
other.path == path &&
215+
listEquals(other.contents, contents);
216+
}
217+
218+
@override
219+
int get hashCode => Object.hashAll([contents, path]);
187220
}
188221

189222
/// Signature for the `createFile` method on [DirectoryGeneratorTarget].
@@ -217,7 +250,7 @@ class RestorableDirectoryGeneratorTarget extends DirectoryGeneratorTarget {
217250
/// Cache the latest recorded snapshot.
218251
void cacheLatestSnapshot() {
219252
final snapshot = _latestSnapshot;
220-
if (snapshot == null) return;
253+
if (snapshot == null || _cachedSnapshot == snapshot) return;
221254
_cachedSnapshot = snapshot;
222255
}
223256

packages/dart_frog_cli/lib/src/commands/dev/templates/dart_frog_dev_server_bundle.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dart_frog_cli/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies:
1414
mason: ^0.1.0-dev.31
1515
meta: ^1.7.0
1616
path: ^1.8.1
17+
stream_transform: ^2.0.0
1718
watcher: ^1.0.1
1819

1920
dev_dependencies:

packages/dart_frog_cli/test/src/commands/dev/dev_test.dart

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,9 @@ void main() {
177177
]);
178178
});
179179

180-
test('runs codegen when changes are made to the public/routes directory',
181-
() async {
180+
test(
181+
'runs codegen w/debounce '
182+
'when changes are made to the public/routes directory', () async {
182183
final controller = StreamController<WatchEvent>();
183184
final generatorHooks = _MockGeneratorHooks();
184185
when(
@@ -219,14 +220,22 @@ void main() {
219220
),
220221
).called(1);
221222

222-
controller.add(
223-
WatchEvent(
224-
ChangeType.MODIFY,
225-
path.join(Directory.current.path, 'routes', 'index.dart'),
226-
),
227-
);
223+
controller
224+
..add(
225+
WatchEvent(
226+
ChangeType.ADD,
227+
path.join(Directory.current.path, 'routes', 'users.dart'),
228+
),
229+
)
230+
..add(
231+
WatchEvent(
232+
ChangeType.REMOVE,
233+
path.join(Directory.current.path, 'routes', 'user.dart'),
234+
),
235+
);
228236

229237
await Future<void>.delayed(Duration.zero);
238+
await Future<void>.delayed(Duration.zero);
230239

231240
verify(
232241
() => generator.generate(
@@ -243,6 +252,7 @@ void main() {
243252
),
244253
);
245254

255+
await Future<void>.delayed(Duration.zero);
246256
await Future<void>.delayed(Duration.zero);
247257

248258
verify(
@@ -260,6 +270,7 @@ void main() {
260270
),
261271
);
262272

273+
await Future<void>.delayed(Duration.zero);
263274
await Future<void>.delayed(Duration.zero);
264275

265276
verifyNever(
@@ -354,6 +365,56 @@ void main() {
354365
verify(() => logger.err(error)).called(1);
355366
});
356367

368+
test('ignores file not found errors due to file renames', () async {
369+
final generatorHooks = _MockGeneratorHooks();
370+
final stdoutController = StreamController<List<int>>();
371+
final stderrController = StreamController<List<int>>();
372+
when(
373+
() => generatorHooks.preGen(
374+
vars: any(named: 'vars'),
375+
workingDirectory: any(named: 'workingDirectory'),
376+
onVarsChanged: any(named: 'onVarsChanged'),
377+
),
378+
).thenAnswer((invocation) async {
379+
(invocation.namedArguments[const Symbol('onVarsChanged')] as Function(
380+
Map<String, dynamic> vars,
381+
))
382+
.call(<String, dynamic>{});
383+
});
384+
when(
385+
() => generator.generate(
386+
any(),
387+
vars: any(named: 'vars'),
388+
fileConflictResolution: FileConflictResolution.overwrite,
389+
),
390+
).thenAnswer((_) async => []);
391+
when(() => generator.hooks).thenReturn(generatorHooks);
392+
when(() => generatorTarget.restore()).thenAnswer((_) async {});
393+
when(() => process.stdout).thenAnswer((_) => stdoutController.stream);
394+
when(() => process.stderr).thenAnswer((_) => stderrController.stream);
395+
when(() => directoryWatcher.events).thenAnswer(
396+
(_) => const Stream.empty(),
397+
);
398+
399+
command.run().ignore();
400+
401+
stdoutController.add(utf8.encode('[hotreload] hot reload enabled'));
402+
await untilCalled(() => generatorTarget.cacheLatestSnapshot());
403+
verify(() => generatorTarget.cacheLatestSnapshot()).called(1);
404+
405+
const error =
406+
"./dart_frog/server.dart:7:8: Error: Error when reading 'routes/example.dart': The system cannot find the file specified.";
407+
408+
stderrController.add(utf8.encode(error));
409+
410+
await stderrController.close();
411+
await stdoutController.close();
412+
413+
verifyNever(() => generatorTarget.cacheLatestSnapshot());
414+
verifyNever(() => generatorTarget.restore());
415+
verifyNever(() => logger.err(error));
416+
});
417+
357418
test('port can be specified using --port', () async {
358419
when<dynamic>(() => argResults['port']).thenReturn('4242');
359420
final generatorHooks = _MockGeneratorHooks();

0 commit comments

Comments
 (0)