Skip to content

Commit 7d73bda

Browse files
authored
Add support for reloading static files (#27744)
* Add support for reloading static files * Update vanilla css files without a browser reload * Refresh the browser when changes to other static content is detected.
1 parent 692bfd6 commit 7d73bda

34 files changed

+582
-162
lines changed

src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
#nullable disable
5+
46
using System;
57
using System.Collections.Generic;
68
using System.Diagnostics;

src/Tools/Shared/TestHelpers/TemporaryDirectory.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
#nullable enable
5+
46
using System;
57
using System.Collections.Generic;
68
using System.IO;
@@ -12,7 +14,7 @@ public class TemporaryDirectory : IDisposable
1214
private List<TemporaryCSharpProject> _projects = new List<TemporaryCSharpProject>();
1315
private List<TemporaryDirectory> _subdirs = new List<TemporaryDirectory>();
1416
private Dictionary<string, string> _files = new Dictionary<string, string>();
15-
private TemporaryDirectory _parent;
17+
private TemporaryDirectory? _parent;
1618

1719
public TemporaryDirectory()
1820
{

src/Tools/Shared/TestHelpers/TestConsole.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Microsoft.Extensions.Tools.Internal
1313
{
1414
public class TestConsole : IConsole
1515
{
16-
private event ConsoleCancelEventHandler _cancelKeyPress;
16+
private event ConsoleCancelEventHandler _cancelKeyPress = default!;
1717
private readonly TaskCompletionSource<bool> _cancelKeySubscribed = new TaskCompletionSource<bool>();
1818
private readonly TestOutputWriter _testWriter;
1919

src/Tools/dotnet-watch/BrowserRefresh/src/WebSocketScriptInjection.js

+41-1
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,55 @@ setTimeout(function () {
88
return;
99
}
1010
connection.onmessage = function (message) {
11+
const updateStaticFileMessage = 'UpdateStaticFile||';
12+
1113
if (message.data === 'Reload') {
1214
console.debug('Server is ready. Reloading...');
1315
location.reload();
1416
} else if (message.data === 'Wait') {
1517
console.debug('File changes detected. Waiting for application to rebuild.');
16-
const t = document.title; const r = ['☱', '☲', '☴']; let i = 0;
18+
const t = document.title;
19+
const r = ['☱', '☲', '☴'];
20+
let i = 0;
1721
setInterval(function () { document.title = r[i++ % r.length] + ' ' + t; }, 240);
22+
} else if (message.data.startsWith(updateStaticFileMessage)) {
23+
const fileName = message.data.substring(updateStaticFileMessage.length);
24+
if (!fileName.endsWith('.css')) {
25+
console.debug(`File change detected to static content file ${fileName}. Reloading page...`);
26+
location.reload();
27+
return;
28+
}
29+
30+
const styleElement = document.querySelector(`link[href^="${fileName}"]`) ||
31+
document.querySelector(`link[href^="${document.baseURI}${fileName}"]`);
32+
if (styleElement && styleElement.parentNode) {
33+
if (styleElement.loading) {
34+
// A file change notification may be triggered for the same file before the browser
35+
// finishes processing a previous update. In this case, it's easiest to ignore later updates
36+
return;
37+
}
38+
39+
const newElement = styleElement.cloneNode();
40+
const href = styleElement.href;
41+
newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`;
42+
43+
styleElement.loading = true;
44+
newElement.loading = true;
45+
newElement.addEventListener('load', function () {
46+
newElement.loading = false;
47+
styleElement.remove();
48+
});
49+
50+
styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling);
51+
} else {
52+
console.debug('Unable to find a stylesheet to update. Reloading the page.');
53+
location.reload();
54+
}
55+
} else {
56+
console.debug('Unknown browser-refresh message received: ', message.data);
1857
}
1958
}
59+
2060
connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) }
2161
connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') }
2262
connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') }

src/Tools/dotnet-watch/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Some configuration options can be passed to `dotnet watch` through environment v
3232
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | By default, `dotnet watch` optimizes the build by avoiding certain operations such as running restore or re-evaluating the set of watched files on every file change. If set to "1" or "true", these optimizations are disabled. |
3333
| DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER | `dotnet watch run` will attempt to launch browsers for web apps with `launchBrowser` configured in `launchSettings.json`. If set to "1" or "true", this behavior is suppressed. |
3434
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | `dotnet watch run` will attempt to refresh browsers when it detects file changes. If set to "1" or "true", this behavior is suppressed. This behavior is also suppressed if DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is set. |
35+
| DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING | If set to "1", or "true", `dotnet watch` will not perform special handling for static content file
36+
3537
### MSBuild
3638

3739
dotnet-watch can be configured from the MSBuild project file being watched.

src/Tools/dotnet-watch/src/BrowserRefreshServer.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Linq;
66
using System.Net.WebSockets;
7+
using System.Text;
78
using System.Threading;
89
using System.Threading.Tasks;
910
using Microsoft.AspNetCore.Builder;
@@ -20,6 +21,8 @@ namespace Microsoft.DotNet.Watcher.Tools
2021
{
2122
public class BrowserRefreshServer : IAsyncDisposable
2223
{
24+
private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload");
25+
private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait");
2326
private readonly IReporter _reporter;
2427
private readonly TaskCompletionSource _taskCompletionSource;
2528
private IHost _refreshServer;
@@ -73,7 +76,7 @@ private async Task WebSocketRequest(HttpContext context)
7376
await _taskCompletionSource.Task;
7477
}
7578

76-
public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default)
79+
public async virtual ValueTask SendMessage(ReadOnlyMemory<byte> messageBytes, CancellationToken cancellationToken = default)
7780
{
7881
if (_webSocket == null || _webSocket.CloseStatus.HasValue)
7982
{
@@ -105,5 +108,9 @@ public async ValueTask DisposeAsync()
105108

106109
_taskCompletionSource.TrySetResult();
107110
}
111+
112+
public ValueTask ReloadAsync(CancellationToken cancellationToken) => SendMessage(ReloadMessage, cancellationToken);
113+
114+
public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendMessage(WaitMessage, cancellationToken);
108115
}
109116
}

src/Tools/dotnet-watch/src/DotNetWatchContext.cs

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ public class DotNetWatchContext
1111

1212
public ProcessSpec ProcessSpec { get; set; }
1313

14-
public IFileSet FileSet { get; set; }
14+
public FileSet FileSet { get; set; }
1515

1616
public int Iteration { get; set; }
1717

18-
public string ChangedFile { get; set; }
18+
public FileItem? ChangedFile { get; set; }
1919

2020
public bool RequiresMSBuildRevaluation { get; set; }
2121

2222
public bool SuppressMSBuildIncrementalism { get; set; }
23+
24+
public BrowserRefreshServer BrowserRefreshServer { get; set; }
2325
}
2426
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace Microsoft.DotNet.Watcher
7+
{
8+
public record DotNetWatchOptions(
9+
bool SuppressHandlingStaticContentFiles,
10+
bool SuppressMSBuildIncrementalism)
11+
{
12+
public static DotNetWatchOptions Default { get; } = new DotNetWatchOptions
13+
(
14+
SuppressHandlingStaticContentFiles: GetSuppressedValue("DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING"),
15+
SuppressMSBuildIncrementalism: GetSuppressedValue("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM")
16+
);
17+
18+
private static bool GetSuppressedValue(string key)
19+
{
20+
var envValue = Environment.GetEnvironmentVariable(key);
21+
return envValue == "1" || envValue == "true";
22+
}
23+
}
24+
}

src/Tools/dotnet-watch/src/DotNetWatcher.cs

+32-11
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Diagnostics;
56
using System.Globalization;
6-
using System.IO;
77
using System.Linq;
88
using System.Threading;
99
using System.Threading.Tasks;
@@ -18,14 +18,16 @@ public class DotNetWatcher : IAsyncDisposable
1818
{
1919
private readonly IReporter _reporter;
2020
private readonly ProcessRunner _processRunner;
21+
private readonly DotNetWatchOptions _dotnetWatchOptions;
2122
private readonly IWatchFilter[] _filters;
2223

23-
public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
24+
public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions)
2425
{
2526
Ensure.NotNull(reporter, nameof(reporter));
2627

2728
_reporter = reporter;
2829
_processRunner = new ProcessRunner(reporter);
30+
_dotnetWatchOptions = dotNetWatchOptions;
2931

3032
_filters = new IWatchFilter[]
3133
{
@@ -44,13 +46,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
4446
cancelledTaskSource);
4547

4648
var initialArguments = processSpec.Arguments.ToArray();
47-
var suppressMSBuildIncrementalism = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM");
4849
var context = new DotNetWatchContext
4950
{
5051
Iteration = -1,
5152
ProcessSpec = processSpec,
5253
Reporter = _reporter,
53-
SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism == "1" || suppressMSBuildIncrementalism == "true",
54+
SuppressMSBuildIncrementalism = _dotnetWatchOptions.SuppressMSBuildIncrementalism,
5455
};
5556

5657
if (context.SuppressMSBuildIncrementalism)
@@ -93,15 +94,34 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
9394
currentRunCancellationSource.Token))
9495
using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter))
9596
{
96-
var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
9797
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);
98-
9998
var args = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments);
10099
_reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}");
101100

102101
_reporter.Output("Started");
103102

104-
var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
103+
Task<FileItem?> fileSetTask;
104+
Task finishedTask;
105+
106+
while (true)
107+
{
108+
fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
109+
finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
110+
111+
if (context.BrowserRefreshServer is not null &&
112+
finishedTask == fileSetTask &&
113+
fileSetTask.Result is FileItem { FileKind: FileKind.StaticFile } file)
114+
{
115+
_reporter.Verbose($"Handling file change event for static content {file.FilePath}.");
116+
117+
// If we can handle the file change without a browser refresh, do it.
118+
await StaticContentHandler.TryHandleFileAction(context.BrowserRefreshServer, file, combinedCancellationSource.Token);
119+
}
120+
else
121+
{
122+
break;
123+
}
124+
}
105125

106126
// Regardless of the which task finished first, make sure everything is cancelled
107127
// and wait for dotnet to exit. We don't want orphan processes
@@ -125,18 +145,19 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
125145
return;
126146
}
127147

128-
context.ChangedFile = fileSetTask.Result;
129148
if (finishedTask == processTask)
130149
{
131150
// Process exited. Redo evaludation
132151
context.RequiresMSBuildRevaluation = true;
133152
// Now wait for a file to change before restarting process
134153
context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet..."));
135154
}
136-
137-
if (!string.IsNullOrEmpty(fileSetTask.Result))
155+
else
138156
{
139-
_reporter.Output($"File changed: {fileSetTask.Result}");
157+
Debug.Assert(finishedTask == fileSetTask);
158+
var changedFile = fileSetTask.Result;
159+
context.ChangedFile = changedFile;
160+
_reporter.Output($"File changed: {changedFile.Value.FilePath}");
140161
}
141162
}
142163
}
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.DotNet.Watcher
5+
{
6+
public readonly struct FileItem
7+
{
8+
public FileItem(string filePath, FileKind fileKind = FileKind.Default, string staticWebAssetPath = null)
9+
{
10+
FilePath = filePath;
11+
FileKind = fileKind;
12+
StaticWebAssetPath = staticWebAssetPath;
13+
}
14+
15+
public string FilePath { get; }
16+
17+
public FileKind FileKind { get; }
18+
19+
public string StaticWebAssetPath { get; }
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4-
using System.Collections.Generic;
5-
64
namespace Microsoft.DotNet.Watcher
75
{
8-
public interface IFileSet : IEnumerable<string>
6+
public enum FileKind
97
{
10-
bool IsNetCoreApp31OrNewer { get; }
11-
12-
bool Contains(string filePath);
8+
Default,
9+
StaticFile,
1310
}
1411
}

src/Tools/dotnet-watch/src/FileSet.cs

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections;
6+
using System.Collections.Generic;
7+
8+
namespace Microsoft.DotNet.Watcher
9+
{
10+
public class FileSet : IEnumerable<FileItem>
11+
{
12+
private readonly Dictionary<string, FileItem> _files;
13+
14+
public FileSet(bool isNetCoreApp31OrNewer, IEnumerable<FileItem> files)
15+
{
16+
IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
17+
_files = new Dictionary<string, FileItem>(StringComparer.Ordinal);
18+
foreach (var item in files)
19+
{
20+
_files[item.FilePath] = item;
21+
}
22+
}
23+
24+
public bool TryGetValue(string filePath, out FileItem fileItem) => _files.TryGetValue(filePath, out fileItem);
25+
26+
public int Count => _files.Count;
27+
28+
public bool IsNetCoreApp31OrNewer { get; }
29+
30+
public static readonly FileSet Empty = new FileSet(false, Array.Empty<FileItem>());
31+
32+
public IEnumerator<FileItem> GetEnumerator() => _files.Values.GetEnumerator();
33+
34+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.Threading;
55
using System.Threading.Tasks;
6+
using Microsoft.DotNet.Watcher.Internal;
67

78
namespace Microsoft.DotNet.Watcher
89
{
910
public interface IFileSetFactory
1011
{
11-
Task<IFileSet> CreateAsync(CancellationToken cancellationToken);
12+
Task<FileSet> CreateAsync(CancellationToken cancellationToken);
1213
}
13-
}
14+
}

0 commit comments

Comments
 (0)