Skip to content

Commit 5740eb5

Browse files
authored
Make several changes to improve the reliability of --watch mode (#2444)
1. The import cache now tracks the most recent time it actually loaded a stylesheet, so that `--watch` mode can invalidate cached data if it's older than the mtime on disk. This should help catch some cases where the watcher information doesn't match the filesystem. 2. Rather than eagerly recompiling as soon as it knows it needs to, the stylesheet graph now only starts recompiling once it's finished processing a batch of events. This ensures that any cache invalidation is finished before the recompilation happens. 3. The stylesheet graph and import cache now eagerly invalidate all canonicalize results that could be changed by an added or removed file, rather than only those that are implicated by the in-memory ASTs. This avoids issues when the in-memory AST is stale. Closes #2440
1 parent 63ebf16 commit 5740eb5

11 files changed

+145
-70
lines changed

CHANGELOG.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
## 1.82.0-dev
1+
## 1.82.0
2+
3+
### Command-Line Interface
4+
5+
* Improve `--watch` mode reliability when making multiple changes at once, such
6+
as checking out a different Git branch.
27

38
* Parse the `calc-size()` function as a calculation now that it's supported in
49
some browsers.

lib/src/async_import_cache.dart

+28-8
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ final class AsyncImportCache {
6969
/// The import results for each canonicalized import URL.
7070
final _resultsCache = <Uri, ImporterResult>{};
7171

72+
/// A map from canonical URLs to the most recent time at which those URLs were
73+
/// loaded from their importers.
74+
final _loadTimes = <Uri, DateTime>{};
75+
7276
/// Creates an import cache that resolves imports using [importers].
7377
///
7478
/// Imports are resolved by trying, in order:
@@ -282,9 +286,11 @@ final class AsyncImportCache {
282286
Future<Stylesheet?> importCanonical(AsyncImporter importer, Uri canonicalUrl,
283287
{Uri? originalUrl}) async {
284288
return await putIfAbsentAsync(_importCache, canonicalUrl, () async {
289+
var loadTime = DateTime.now();
285290
var result = await importer.load(canonicalUrl);
286291
if (result == null) return null;
287292

293+
_loadTimes[canonicalUrl] = loadTime;
288294
_resultsCache[canonicalUrl] = result;
289295
return Stylesheet.parse(result.contents, result.syntax,
290296
// For backwards-compatibility, relative canonical URLs are resolved
@@ -320,17 +326,31 @@ final class AsyncImportCache {
320326
Uri sourceMapUrl(Uri canonicalUrl) =>
321327
_resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl;
322328

323-
/// Clears the cached canonical version of the given non-canonical [url].
324-
///
325-
/// Has no effect if the canonical version of [url] has not been cached.
329+
/// Returns the most recent time the stylesheet at [canonicalUrl] was loaded
330+
/// from its importer, or `null` if it has never been loaded.
331+
@internal
332+
DateTime? loadTime(Uri canonicalUrl) => _loadTimes[canonicalUrl];
333+
334+
/// Clears all cached canonicalizations that could potentially produce
335+
/// [canonicalUrl].
326336
///
327337
/// @nodoc
328338
@internal
329-
void clearCanonicalize(Uri url) {
330-
_canonicalizeCache.remove((url, forImport: false));
331-
_canonicalizeCache.remove((url, forImport: true));
332-
_perImporterCanonicalizeCache.removeWhere(
333-
(key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url);
339+
Future<void> clearCanonicalize(Uri canonicalUrl) async {
340+
for (var key in [..._canonicalizeCache.keys]) {
341+
for (var importer in _importers) {
342+
if (await importer.couldCanonicalize(key.$1, canonicalUrl)) {
343+
_canonicalizeCache.remove(key);
344+
break;
345+
}
346+
}
347+
}
348+
349+
for (var key in [..._perImporterCanonicalizeCache.keys]) {
350+
if (await key.$1.couldCanonicalize(key.$2, canonicalUrl)) {
351+
_perImporterCanonicalizeCache.remove(key);
352+
}
353+
}
334354
}
335355

336356
/// Clears the cached parse tree for the stylesheet with the given

lib/src/executable/compile_stylesheet.dart

+4
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ Future<void> _compileStylesheetWithoutErrorHandling(ExecutableOptions options,
124124
fatalDeprecations: options.fatalDeprecations,
125125
futureDeprecations: options.futureDeprecations);
126126
} else {
127+
// Double-check that all modified files (according to mtime) are actually
128+
// reloaded in the graph so we don't end up with stale ASTs.
129+
graph.reloadAllModified();
130+
127131
result = source == null
128132
? compileString(await readStdin(),
129133
syntax: syntax,

lib/src/executable/watch.dart

+44-46
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ final class _Watcher {
6262
/// The graph of stylesheets being compiled.
6363
final StylesheetGraph _graph;
6464

65+
/// A map from source paths to destinations that need to be recompiled once
66+
/// the current batch of events has been processed.
67+
final Map<String, String> _toRecompile = {};
68+
6569
_Watcher(this._options, this._graph);
6670

6771
/// Deletes the file at [path] and prints a message about it.
@@ -82,70 +86,75 @@ final class _Watcher {
8286
///
8387
/// Returns a future that will only complete if an unexpected error occurs.
8488
Future<void> watch(MultiDirWatcher watcher) async {
85-
await for (var event in _debounceEvents(watcher.events)) {
86-
var extension = p.extension(event.path);
87-
if (extension != '.sass' && extension != '.scss' && extension != '.css') {
88-
continue;
89+
await for (var batch in _debounceEvents(watcher.events)) {
90+
for (var event in batch) {
91+
var extension = p.extension(event.path);
92+
if (extension != '.sass' &&
93+
extension != '.scss' &&
94+
extension != '.css') {
95+
continue;
96+
}
97+
98+
switch (event.type) {
99+
case ChangeType.MODIFY:
100+
_handleModify(event.path);
101+
102+
case ChangeType.ADD:
103+
_handleAdd(event.path);
104+
105+
case ChangeType.REMOVE:
106+
_handleRemove(event.path);
107+
}
89108
}
90109

91-
switch (event.type) {
92-
case ChangeType.MODIFY:
93-
var success = await _handleModify(event.path);
94-
if (!success && _options.stopOnError) return;
95-
96-
case ChangeType.ADD:
97-
var success = await _handleAdd(event.path);
98-
if (!success && _options.stopOnError) return;
99-
100-
case ChangeType.REMOVE:
101-
var success = await _handleRemove(event.path);
102-
if (!success && _options.stopOnError) return;
103-
}
110+
var toRecompile = {..._toRecompile};
111+
_toRecompile.clear();
112+
var success = await compileStylesheets(_options, _graph, toRecompile,
113+
ifModified: true);
114+
if (!success && _options.stopOnError) return;
104115
}
105116
}
106117

107118
/// Handles a modify event for the stylesheet at [path].
108119
///
109120
/// Returns whether all necessary recompilations succeeded.
110-
Future<bool> _handleModify(String path) async {
121+
void _handleModify(String path) {
111122
var url = _canonicalize(path);
112123

113124
// It's important to access the node ahead-of-time because it's possible
114125
// that `_graph.reload()` notices the file has been deleted and removes it
115126
// from the graph.
116127
if (_graph.nodes[url] case var node?) {
117128
_graph.reload(url);
118-
return await _recompileDownstream([node]);
129+
_recompileDownstream([node]);
119130
} else {
120-
return _handleAdd(path);
131+
_handleAdd(path);
121132
}
122133
}
123134

124135
/// Handles an add event for the stylesheet at [url].
125136
///
126137
/// Returns whether all necessary recompilations succeeded.
127-
Future<bool> _handleAdd(String path) async {
138+
void _handleAdd(String path) {
128139
var destination = _destinationFor(path);
129-
var success = destination == null ||
130-
await compileStylesheets(_options, _graph, {path: destination},
131-
ifModified: true);
140+
if (destination != null) _toRecompile[path] = destination;
132141
var downstream = _graph.addCanonical(
133142
FilesystemImporter.cwd, _canonicalize(path), p.toUri(path));
134-
return await _recompileDownstream(downstream) && success;
143+
_recompileDownstream(downstream);
135144
}
136145

137146
/// Handles a remove event for the stylesheet at [url].
138147
///
139148
/// Returns whether all necessary recompilations succeeded.
140-
Future<bool> _handleRemove(String path) async {
149+
void _handleRemove(String path) async {
141150
var url = _canonicalize(path);
142151

143152
if (_graph.nodes.containsKey(url)) {
144153
if (_destinationFor(path) case var destination?) _delete(destination);
145154
}
146155

147156
var downstream = _graph.remove(FilesystemImporter.cwd, url);
148-
return await _recompileDownstream(downstream);
157+
_recompileDownstream(downstream);
149158
}
150159

151160
/// Returns the canonical URL for the stylesheet path [path].
@@ -154,9 +163,10 @@ final class _Watcher {
154163
/// Combine [WatchEvent]s that happen in quick succession.
155164
///
156165
/// Otherwise, if a file is erased and then rewritten, we can end up reading
157-
/// the intermediate erased version.
158-
Stream<WatchEvent> _debounceEvents(Stream<WatchEvent> events) {
159-
return events.debounceBuffer(Duration(milliseconds: 25)).expand((buffer) {
166+
/// the intermediate erased version. This returns a stream of batches of
167+
/// events that all happened in succession.
168+
Stream<List<WatchEvent>> _debounceEvents(Stream<WatchEvent> events) {
169+
return events.debounceBuffer(Duration(milliseconds: 25)).map((buffer) {
160170
var typeForPath = p.PathMap<ChangeType>();
161171
for (var event in buffer) {
162172
var oldType = typeForPath[event.path];
@@ -175,32 +185,20 @@ final class _Watcher {
175185
});
176186
}
177187

178-
/// Recompiles [nodes] and everything that transitively imports them, if
179-
/// necessary.
180-
///
181-
/// Returns whether all recompilations succeeded.
182-
Future<bool> _recompileDownstream(Iterable<StylesheetNode> nodes) async {
188+
/// Marks [nodes] and everything that transitively imports them for
189+
/// recompilation, if necessary.
190+
void _recompileDownstream(Iterable<StylesheetNode> nodes) {
183191
var seen = <StylesheetNode>{};
184-
var allSucceeded = true;
185192
while (nodes.isNotEmpty) {
186193
nodes = [
187194
for (var node in nodes)
188195
if (seen.add(node)) node
189196
];
190197

191-
var sourcesToDestinations = _sourceEntrypointsToDestinations(nodes);
192-
if (sourcesToDestinations.isNotEmpty) {
193-
var success = await compileStylesheets(
194-
_options, _graph, sourcesToDestinations,
195-
ifModified: true);
196-
if (!success && _options.stopOnError) return false;
197-
198-
allSucceeded = allSucceeded && success;
199-
}
198+
_toRecompile.addAll(_sourceEntrypointsToDestinations(nodes));
200199

201200
nodes = [for (var node in nodes) ...node.downstream];
202201
}
203-
return allSucceeded;
204202
}
205203

206204
/// Returns a sourcesToDestinations mapping for nodes that are entrypoints.

lib/src/import_cache.dart

+29-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// DO NOT EDIT. This file was generated from async_import_cache.dart.
66
// See tool/grind/synchronize.dart for details.
77
//
8-
// Checksum: 4d09da97db4e59518d193f58963897d36ef4db00
8+
// Checksum: 65a7c538299527be3240f0625a7c1cd4f8cd6824
99
//
1010
// ignore_for_file: unused_import
1111

@@ -70,6 +70,10 @@ final class ImportCache {
7070
/// The import results for each canonicalized import URL.
7171
final _resultsCache = <Uri, ImporterResult>{};
7272

73+
/// A map from canonical URLs to the most recent time at which those URLs were
74+
/// loaded from their importers.
75+
final _loadTimes = <Uri, DateTime>{};
76+
7377
/// Creates an import cache that resolves imports using [importers].
7478
///
7579
/// Imports are resolved by trying, in order:
@@ -276,9 +280,11 @@ final class ImportCache {
276280
Stylesheet? importCanonical(Importer importer, Uri canonicalUrl,
277281
{Uri? originalUrl}) {
278282
return _importCache.putIfAbsent(canonicalUrl, () {
283+
var loadTime = DateTime.now();
279284
var result = importer.load(canonicalUrl);
280285
if (result == null) return null;
281286

287+
_loadTimes[canonicalUrl] = loadTime;
282288
_resultsCache[canonicalUrl] = result;
283289
return Stylesheet.parse(result.contents, result.syntax,
284290
// For backwards-compatibility, relative canonical URLs are resolved
@@ -314,17 +320,31 @@ final class ImportCache {
314320
Uri sourceMapUrl(Uri canonicalUrl) =>
315321
_resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl;
316322

317-
/// Clears the cached canonical version of the given non-canonical [url].
318-
///
319-
/// Has no effect if the canonical version of [url] has not been cached.
323+
/// Returns the most recent time the stylesheet at [canonicalUrl] was loaded
324+
/// from its importer, or `null` if it has never been loaded.
325+
@internal
326+
DateTime? loadTime(Uri canonicalUrl) => _loadTimes[canonicalUrl];
327+
328+
/// Clears all cached canonicalizations that could potentially produce
329+
/// [canonicalUrl].
320330
///
321331
/// @nodoc
322332
@internal
323-
void clearCanonicalize(Uri url) {
324-
_canonicalizeCache.remove((url, forImport: false));
325-
_canonicalizeCache.remove((url, forImport: true));
326-
_perImporterCanonicalizeCache.removeWhere(
327-
(key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url);
333+
void clearCanonicalize(Uri canonicalUrl) {
334+
for (var key in [..._canonicalizeCache.keys]) {
335+
for (var importer in _importers) {
336+
if (importer.couldCanonicalize(key.$1, canonicalUrl)) {
337+
_canonicalizeCache.remove(key);
338+
break;
339+
}
340+
}
341+
}
342+
343+
for (var key in [..._perImporterCanonicalizeCache.keys]) {
344+
if (key.$1.couldCanonicalize(key.$2, canonicalUrl)) {
345+
_perImporterCanonicalizeCache.remove(key);
346+
}
347+
}
328348
}
329349

330350
/// Clears the cached parse tree for the stylesheet with the given

lib/src/stylesheet_graph.dart

+29-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
88
import 'ast/sass.dart';
99
import 'import_cache.dart';
1010
import 'importer.dart';
11+
import 'io.dart';
1112
import 'util/map.dart';
1213
import 'util/nullable.dart';
1314
import 'visitor/find_dependencies.dart';
@@ -169,6 +170,33 @@ class StylesheetGraph {
169170
return true;
170171
}
171172

173+
/// Re-parses all stylesheets in the graph that have been modified on disk
174+
/// since their last known in-memory modification.
175+
///
176+
/// This guards against situations where a recompilation is triggered before
177+
/// the graph is manually informed of all changes, such as when `--poll` runs
178+
/// slowly or native file system notifications aren't comprehensive.
179+
void reloadAllModified() {
180+
// Copy to a list because [reload] can modify [_nodes].
181+
for (var node in [..._nodes.values]) {
182+
var modified = false;
183+
try {
184+
var loadTime = importCache.loadTime(node.canonicalUrl);
185+
modified = loadTime != null &&
186+
node.importer.modificationTime(node.canonicalUrl).isAfter(loadTime);
187+
} on FileSystemException catch (_) {
188+
// If the file no longer exists, treat that as a modification.
189+
modified = true;
190+
}
191+
192+
if (modified) {
193+
if (!reload(node.canonicalUrl)) {
194+
remove(node.importer, node.canonicalUrl);
195+
}
196+
}
197+
}
198+
}
199+
172200
/// Removes the stylesheet at [canonicalUrl] (loaded by [importer]) from the
173201
/// stylesheet graph.
174202
///
@@ -204,6 +232,7 @@ class StylesheetGraph {
204232
/// Returns all nodes whose imports were changed.
205233
Set<StylesheetNode> _recanonicalizeImports(
206234
Importer importer, Uri canonicalUrl) {
235+
importCache.clearCanonicalize(canonicalUrl);
207236
var changed = <StylesheetNode>{};
208237
for (var node in nodes.values) {
209238
var newUpstream = _recanonicalizeImportsForNode(
@@ -242,7 +271,6 @@ class StylesheetGraph {
242271
var newMap = <Uri, StylesheetNode?>{};
243272
for (var (url, upstream) in map.pairs) {
244273
if (!importer.couldCanonicalize(url, canonicalUrl)) continue;
245-
importCache.clearCanonicalize(url);
246274

247275
// If the import produces a different canonicalized URL than it did
248276
// before, it changed and the stylesheet needs to be recompiled.

pkg/sass-parser/CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## 0.4.7-dev
1+
## 0.4.7
22

33
* No user-visible changes.
44

pkg/sass-parser/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sass-parser",
3-
"version": "0.4.7-dev",
3+
"version": "0.4.7",
44
"description": "A PostCSS-compatible wrapper of the official Sass parser",
55
"repository": "sass/sass",
66
"author": "Google Inc.",

pkg/sass_api/CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## 14.4.0-dev
1+
## 14.4.0
22

33
* No user-visible changes.
44

0 commit comments

Comments
 (0)