-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Static content hot reload (CSS only for now) #6097
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
44d5b8d
Inject manager component across all platforms
SteveSandersonMS e88f34d
Manager component ready to notify the JS code when update received
SteveSandersonMS 27cf9e3
Override response content and notify about changes
SteveSandersonMS a5609a4
Switch to storing things by absolute URL as it's simpler to match
SteveSandersonMS 1ead8e2
Cause CSS to refresh
SteveSandersonMS 01b7314
Fixes for Android and for scoped CSS
SteveSandersonMS 2f2f7c0
Properly account for configurable content roots
SteveSandersonMS 5865584
Simplify by refreshing all CSS files when any change
SteveSandersonMS cab95c1
Clean up
SteveSandersonMS 81d72a8
Workaround Android issue
SteveSandersonMS File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
160 changes: 160 additions & 0 deletions
160
src/BlazorWebView/src/SharedSource/StaticContentHotReloadManager.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.IO; | ||
using System.Reflection; | ||
using System.Reflection.Metadata; | ||
using System.Text; | ||
using System.Text.RegularExpressions; | ||
using System.Threading.Tasks; | ||
using Microsoft.Extensions.Logging; | ||
using Microsoft.JSInterop; | ||
|
||
[assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager))] | ||
|
||
namespace Microsoft.AspNetCore.Components.WebView | ||
{ | ||
internal static class StaticContentHotReloadManager | ||
{ | ||
private delegate void ContentUpdatedHandler(string assemblyName, string relativePath); | ||
|
||
private readonly static Regex ContentUrlRegex = new Regex("^_content/(?<AssemblyName>[^/]+)/(?<RelativePath>.*)"); | ||
private static event ContentUpdatedHandler? OnContentUpdated; | ||
|
||
private static string? ApplicationAssemblyName { get; } = Assembly.GetEntryAssembly()?.GetName().Name; | ||
|
||
private static readonly Dictionary<(string? AssemblyName, string RelativePath), (string? ContentType, byte[] Content)> _updatedContent = new() | ||
{ | ||
{ (ApplicationAssemblyName, "_framework/static-content-hot-reload.js"), ("text/javascript", Encoding.UTF8.GetBytes(@" | ||
export function notifyCssUpdated() { | ||
const allLinkElems = Array.from(document.querySelectorAll('link[rel=stylesheet]')); | ||
allLinkElems.forEach(elem => elem.href += ''); | ||
} | ||
")) } | ||
}; | ||
|
||
/// <summary> | ||
/// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection. | ||
/// </summary> | ||
public static void UpdateContent(string assemblyName, string relativePath, byte[] content) | ||
{ | ||
_updatedContent[(assemblyName, relativePath)] = (ContentType: null, Content: content); | ||
|
||
// On some platforms (Android), the information about the application assembly name is lost | ||
// at compile-time. As a workaround, we treat the app assembly name as null, and treat all | ||
// hot reloaded files as being in both an RCL and the app assembly. This works fine if the | ||
// names don't clash, but means that RCL files can override an app file if the name does clash. | ||
// TODO: Either have the tooling tell us whether this is the main application project, or | ||
// have MAUI somehow retain the information about application assembly name to runtime. | ||
if (ApplicationAssemblyName is null) | ||
{ | ||
_updatedContent[(null, relativePath)] = (ContentType: null, Content: content); | ||
} | ||
|
||
OnContentUpdated?.Invoke(assemblyName, relativePath); | ||
} | ||
|
||
public static void AttachToWebViewManagerIfEnabled(WebViewManager manager) | ||
{ | ||
if (MetadataUpdater.IsSupported) | ||
{ | ||
manager.AddRootComponentAsync(typeof(StaticContentChangeNotifier), "body::after", ParameterView.Empty); | ||
} | ||
} | ||
|
||
public static bool TryReplaceResponseContent(string contentRootRelativePath, string requestAbsoluteUri, ref int responseStatusCode, ref Stream responseContent, IDictionary<string, string> responseHeaders) | ||
{ | ||
if (MetadataUpdater.IsSupported) | ||
{ | ||
var (assemblyName, relativePath) = GetAssemblyNameAndRelativePath(requestAbsoluteUri, contentRootRelativePath); | ||
if (_updatedContent.TryGetValue((assemblyName, relativePath), out var values)) | ||
{ | ||
responseStatusCode = 200; | ||
responseContent = new MemoryStream(values.Content); | ||
if (!string.IsNullOrEmpty(values.ContentType)) | ||
{ | ||
responseHeaders["Content-Type"] = values.ContentType; | ||
} | ||
|
||
return true; | ||
} | ||
} | ||
|
||
return false; | ||
} | ||
|
||
private static (string? AssemblyName, string RelativePath) GetAssemblyNameAndRelativePath(string requestAbsoluteUri, string appContentRoot) | ||
{ | ||
var requestPath = new Uri(requestAbsoluteUri).AbsolutePath.Substring(1); | ||
if (ContentUrlRegex.Match(requestPath) is { Success: true } match) | ||
{ | ||
// For RCLs (i.e., URLs of the form _content/assembly/path), we assume the content root within the | ||
// RCL to be "wwwroot" since we have no other information. If this is not the case, content within | ||
// that RCL will not be hot-reloadable. | ||
return (match.Groups["AssemblyName"].Value, $"wwwroot/{match.Groups["RelativePath"].Value}"); | ||
} | ||
else if (requestPath.StartsWith("_framework/", StringComparison.Ordinal)) | ||
{ | ||
return (ApplicationAssemblyName, requestPath); | ||
} | ||
else | ||
{ | ||
return (ApplicationAssemblyName, Path.Combine(appContentRoot, requestPath).Replace('\\', '/')); | ||
} | ||
} | ||
|
||
// To provide a consistent way of transporting the data across all platforms, | ||
// we can use the existing IJSRuntime. In turn we can get an instance of this | ||
// that's always attached to the currently-loaded page (if it's a Blazor page) | ||
// by injecting this headless root component. | ||
private sealed class StaticContentChangeNotifier : IComponent, IDisposable | ||
{ | ||
private ILogger _logger = default!; | ||
|
||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; | ||
[Inject] private ILoggerFactory LoggerFactory { get; set; } = default!; | ||
|
||
public void Attach(RenderHandle renderHandle) | ||
{ | ||
_logger = LoggerFactory.CreateLogger<StaticContentChangeNotifier>(); | ||
OnContentUpdated += NotifyContentUpdated; | ||
} | ||
|
||
public void Dispose() | ||
{ | ||
OnContentUpdated -= NotifyContentUpdated; | ||
} | ||
|
||
private void NotifyContentUpdated(string assemblyName, string relativePath) | ||
{ | ||
// It handles its own errors | ||
_ = NotifyContentUpdatedAsync(assemblyName, relativePath); | ||
} | ||
|
||
private async Task NotifyContentUpdatedAsync(string assemblyName, string relativePath) | ||
{ | ||
try | ||
{ | ||
await using var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./_framework/static-content-hot-reload.js"); | ||
|
||
// In the future we might want to hot-reload other content types such as images, but currently the tooling is | ||
// only expected to notify about CSS files. If it notifies us about something else, we'd need different JS logic. | ||
if (string.Equals(".css", Path.GetExtension(relativePath), StringComparison.Ordinal)) | ||
{ | ||
// We could try to supply the URL of the modified file, so the JS-side logic could only update the affected | ||
// stylesheet. This would reduce flicker. However, this involves hardcoding further details about URL conventions | ||
// (e.g., _content/AssemblyName/Path) and accounting for configurable content roots. To reduce the chances of | ||
// CSS hot reload being broken by customizations, we'll have the JS-side code refresh all stylesheets. | ||
await module.InvokeVoidAsync("notifyCssUpdated"); | ||
} | ||
} | ||
catch (Exception ex) | ||
{ | ||
_logger.LogError(ex, $"Failed to notify about static content update to {relativePath}."); | ||
} | ||
} | ||
|
||
public Task SetParametersAsync(ParameterView parameters) | ||
=> Task.CompletedTask; | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that I've put in a request to have an extra parameter to give us this information, so I might be able to remove this workaround soon.