Cache Busting for Static Files in Blazor 8 Across Different Render Modes
Asked Answered
S

3

7

I'm working on a Blazor 8 application and I'm having trouble with browser caching of my static files. When I make changes to the CSS files, the changes are not immediately visible because the browser serves the old version of the file from cache.

In ASP.NET Core, I would use the asp-append-version tag helper to append a version query string to the URL of the CSS or JS file. This version query string changes whenever the file changes, forcing the browser to download the new version of the file instead of serving the old one from cache.

In Blazor and .NET 8, the general page format is defined in the App.razor file. Here's what my App.razor file looks like:

<!DOCTYPE html>
<html lang="fa" dir="rtl">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <link rel="stylesheet" href="fonts/font.css">
    <link rel="stylesheet" href="css/bargeh.css">
    <link rel="stylesheet" href="css/header.css">
    <link rel="stylesheet" href="css/footer.css">
    <script src="js/jquery-3.6.0.min.js"></script>
    <script src="js/header.js" defer></script>
    <script src="js/bargeh.js" defer></script>
    <HeadOutlet />
</head>

<body>
    <Routes />
@* ReSharper disable Html.PathError *@
<script src="_framework/blazor.web.js"></script>
@* ReSharper restore Html.PathError *@
</body>

</html>

As you can see, I'm referencing several CSS and JS files in the head section of the HTML document. I tried manually appending a version query string to the URLs of these files, like this:

<link rel="stylesheet" href="css/bargeh.css?v=1.0.0">

But this approach requires me to manually increment the version number each time I make changes to the CSS file, which is not ideal.

Moreover, my application might use server-side rendering (SSR), server, WebAssembly (WASM), or auto render mode. So, I need a workaround that works for all of these.

Is there a way to automatically append a version query string to the URLs of CSS files in the App.razor file of a Blazor.NET 8 application, similar to how asp-append-version works in ASP.NET? Any help would be greatly appreciated. Thank you!

Stalky answered 30/11, 2023 at 9:23 Comment(0)
F
6

Based on the source codes of LinkTagHelper

if (Href != null)
            {
                var index = output.Attributes.IndexOfName(HrefAttributeName);
                var existingAttribute = output.Attributes[index];
                output.Attributes[index] = new TagHelperAttribute(
                    existingAttribute.Name,
                    FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Href),
                    existingAttribute.ValueStyle);
            }

You may try:

Register the required service:

builder.Services.AddMvc();

Inject IFileVersionProvider

@inject IFileVersionProvider fileversionprovider

append the version:

<head>
    //......
    <base href=@PathBase />
    .....
</head>

<body>
    <Routes />
     //......
    <script src="_framework/blazor.web.js"></script>
    <script src=@FileVersionhref("js/Myjs.js")></script>
</body>
</html>


@code{

    string PathBase="/" ;

    private string FileVersionhref(string path)
    {

        return (fileversionprovider.AddFileVersionToPath(PathBase,path));
    }
    
}

Result:

enter image description here

The implementation class for IFileVersionProvider is DefaultFileVersionProvider:

internal sealed class DefaultFileVersionProvider : IFileVersionProvider
{
    private const string VersionKey = "v";

    public DefaultFileVersionProvider(
        IWebHostEnvironment hostingEnvironment,
        TagHelperMemoryCacheProvider cacheProvider)
    {
        ArgumentNullException.ThrowIfNull(hostingEnvironment);
        ArgumentNullException.ThrowIfNull(cacheProvider);

        FileProvider = hostingEnvironment.WebRootFileProvider;
        Cache = cacheProvider.Cache;
    }

    public IFileProvider FileProvider { get; }

    public IMemoryCache Cache { get; }

    public string AddFileVersionToPath(PathString requestPathBase, string path)
    {
        ArgumentNullException.ThrowIfNull(path);

        var resolvedPath = path;

        var queryStringOrFragmentStartIndex = path.AsSpan().IndexOfAny('?', '#');
        if (queryStringOrFragmentStartIndex != -1)
        {
            resolvedPath = path.Substring(0, queryStringOrFragmentStartIndex);
        }

        if (Uri.TryCreate(resolvedPath, UriKind.Absolute, out var uri) && !uri.IsFile)
        {
            // Don't append version if the path is absolute.
            return path;
        }

        if (Cache.TryGetValue<string>(path, out var value) && value is not null)
        {
            return value;
        }

        var cacheEntryOptions = new MemoryCacheEntryOptions();
        cacheEntryOptions.AddExpirationToken(FileProvider.Watch(resolvedPath));
        var fileInfo = FileProvider.GetFileInfo(resolvedPath);

        if (!fileInfo.Exists &&
            requestPathBase.HasValue &&
            resolvedPath.StartsWith(requestPathBase.Value, StringComparison.OrdinalIgnoreCase))
        {
            var requestPathBaseRelativePath = resolvedPath.Substring(requestPathBase.Value.Length);
            cacheEntryOptions.AddExpirationToken(FileProvider.Watch(requestPathBaseRelativePath));
            fileInfo = FileProvider.GetFileInfo(requestPathBaseRelativePath);
        }

        if (fileInfo.Exists)
        {
            value = QueryHelpers.AddQueryString(path, VersionKey, GetHashForFile(fileInfo));
        }
        else
        {
            // if the file is not in the current server.
            value = path;
        }

        cacheEntryOptions.SetSize(value.Length * sizeof(char));
        Cache.Set(path, value, cacheEntryOptions);
        return value;
    }

    private static string GetHashForFile(IFileInfo fileInfo)
    {
        using (var readStream = fileInfo.CreateReadStream())
        {
            var hash = SHA256.HashData(readStream);
            return WebEncoders.Base64UrlEncode(hash);
        }
    }
}

You couldn't register it directly due to protection level,and if you selectAddMvc(); method and press F12 check the codes related,you won't find pure solution due to protection level either

Flosi answered 1/12, 2023 at 3:33 Comment(3)
Hi. Thanks for your answer. Are you sure that builder.Services.AddMvc() is okay in case of performance? Can you explicitly mention the service that you need to add?Stalky
Hi,@Matin,As far as I know,it won't have much influence on performance,and please check what I've updated in my answer ,I don't think there would be a pure solution that only register the required servicesFlosi
Thanks for looking in the source code. Idk how you did find this. Its again yet another breaking change of updating to .NET 8 Blazor that's probably not documented. With .cshtml one could use @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers and <link href="css/main.css" rel="stylesheet" asp-append-version="true" /> afaik. If anybody actually finds it in the documentation, I'm happy to be wrong.Mena
H
2

Another option to get the same effect as using asp-append-version is to add the assmembly version to the link tags within the app.razor page as follows:

<link rel="stylesheet" href="[email protected]().Assembly.GetName().Version.ToString()" />
Heavenward answered 17/3, 2024 at 18:33 Comment(1)
This works if the files change only when the assembly is updated. This solution will not work properly if the wwwroot files are updated independently.Stalky
P
0

Since I can't comment directly, in addition to @Ruikai's answer above, the minimum registration needed to use the sealed type DefaultFileVersionProvider is:

builder.Services.AddMvcCore().AddRazorViewEngine();

Which is just a subset of what AddMvc() does, however it still adds plenty of other things that you will most likely never need (see code here).

Therefore, alternatively, if you don't mind dabbling with reflection and accepting any potential future breaking changes, you could also do this:

var DefaultFileVersionProvider_type = typeof(IRazorViewEngine).Assembly
    .GetType("Microsoft.AspNetCore.Mvc.Razor.Infrastructure.DefaultFileVersionProvider")!;

builder.Services.TryAddSingleton<TagHelperMemoryCacheProvider>();
builder.Services.TryAddSingleton(typeof(IFileVersionProvider), DefaultFileVersionProvider_type);

This avoids the maximum amount of service bloat and adds only what's needed to use this built-in IFileVersionProvider type within blazor (or anywhere else really).

Papal answered 26/9, 2024 at 16:47 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.