Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,35 @@ internal static class AssetToCompress
{
public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHelper log, out string fullPath)
{
var relatedAsset = assetToCompress.GetMetadata("RelatedAsset");
var relatedAssetOriginalItemSpec = assetToCompress.GetMetadata("RelatedAssetOriginalItemSpec");

var relatedAssetExists = File.Exists(relatedAsset);
var originalItemSpecExists = File.Exists(relatedAssetOriginalItemSpec);

// When both paths exist and point to different files, prefer the newer one.
// This handles incremental builds where the source file (OriginalItemSpec) may be
// newer than the destination (RelatedAsset), which hasn't been copied yet.
if (relatedAssetExists && originalItemSpecExists &&
!string.Equals(relatedAsset, relatedAssetOriginalItemSpec, StringComparison.OrdinalIgnoreCase))
{
var relatedAssetTime = File.GetLastWriteTimeUtc(relatedAsset);
var originalItemSpecTime = File.GetLastWriteTimeUtc(relatedAssetOriginalItemSpec);

if (originalItemSpecTime > relatedAssetTime)
{
log.LogMessage(MessageImportance.Low, "Asset '{0}' using original item spec '{1}' because it is newer than '{2}'.",
assetToCompress.ItemSpec,
relatedAssetOriginalItemSpec,
relatedAsset);
fullPath = relatedAssetOriginalItemSpec;
return true;
}
}
Comment on lines +15 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, no. This task shouldn't worry about incrementalism.

This is an artifact of the magic that we do on webassembly where we define the asset pointing to a non-existing place. We should do that as opposed to checking for newer files here.

this is ~4 file accesses per compression check that will get very costly soon. DefineStaticWebAssets is the only task that is "allowed" to read the file from disk to ensure we only do it once per file. Other than when we need to actually process the file.

This now points out to a bigger problem (surprised that we haven't seen it before) where the outputs from the build are considered inputs to the current one.

We need to do this on the wasm sdk, otherwise doing it here will destroy perf. One way to do this would be to delete the file at the beginning of the build when it changes, but better yet would be to stop defining these assets with an item spec in the wwwroot folder and just define them in their original location on disk.

Copy link
Member Author

@lewing lewing Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I was certain this would annoy you, I think in the common cases it only adds ~one more file exists call (both calls were already in the code the second was just short circuited) but I confess I'm not that familiar with exactly what amounts to common for these cases. The regression is unfortunate so whatever we do lets do it quickly.


// Check RelatedAsset first (the asset's Identity path) as it's more reliable.
// RelatedAssetOriginalItemSpec may point to a project file (e.g., .esproj) rather than the actual asset.
var relatedAsset = assetToCompress.GetMetadata("RelatedAsset");
if (File.Exists(relatedAsset))
if (relatedAssetExists)
{
log.LogMessage(MessageImportance.Low, "Asset '{0}' found at path '{1}'.",
assetToCompress.ItemSpec,
Expand All @@ -24,8 +49,7 @@ public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHe
return true;
}

var relatedAssetOriginalItemSpec = assetToCompress.GetMetadata("RelatedAssetOriginalItemSpec");
if (File.Exists(relatedAssetOriginalItemSpec))
if (originalItemSpecExists)
{
log.LogMessage(MessageImportance.Low, "Asset '{0}' found at original item spec '{1}'.",
assetToCompress.ItemSpec,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,17 @@ public void TryFindInputFilePath_ReturnsError_WhenNeitherPathExists()
public void TryFindInputFilePath_PrefersRelatedAsset_OverRelatedAssetOriginalItemSpec_WhenBothExist()
{
// Arrange - create two files to simulate the scenario where both metadata values point to existing files
// Make RelatedAsset newer to ensure it's preferred in the normal case
var relatedAssetPath = Path.Combine(_testDirectory, "correct-asset.js");
var originalItemSpecPath = Path.Combine(_testDirectory, "project-file.esproj");
File.WriteAllText(relatedAssetPath, "// correct JavaScript content");

// Create originalItemSpec first (older)
File.WriteAllText(originalItemSpecPath, "<Project></Project>");
File.SetLastWriteTimeUtc(originalItemSpecPath, DateTime.UtcNow.AddMinutes(-5));

// Create RelatedAsset second (newer)
File.WriteAllText(relatedAssetPath, "// correct JavaScript content");
File.SetLastWriteTimeUtc(relatedAssetPath, DateTime.UtcNow);

var assetToCompress = new TaskItem("test.js.gz");
assetToCompress.SetMetadata("RelatedAsset", relatedAssetPath);
Expand Down Expand Up @@ -152,8 +159,14 @@ public void TryFindInputFilePath_HandlesEsprojScenario_WhereOriginalItemSpecPoin
var actualJsFile = Path.Combine(_testDirectory, "dist", "app.min.js");

Directory.CreateDirectory(Path.GetDirectoryName(actualJsFile));

// Create esproj first (older)
File.WriteAllText(esprojFile, "<Project Sdk=\"Microsoft.VisualStudio.JavaScript.Sdk\"></Project>");
File.SetLastWriteTimeUtc(esprojFile, DateTime.UtcNow.AddMinutes(-5));

// Create actual JS file second (newer)
File.WriteAllText(actualJsFile, "// actual JavaScript content");
File.SetLastWriteTimeUtc(actualJsFile, DateTime.UtcNow);

var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "app.min.js.gz"));
// RelatedAsset should contain the correct path to the actual JS file
Expand All @@ -170,4 +183,92 @@ public void TryFindInputFilePath_HandlesEsprojScenario_WhereOriginalItemSpecPoin
fullPath.Should().NotBe(esprojFile);
_errorMessages.Should().BeEmpty();
}

[Fact]
public void TryFindInputFilePath_PrefersNewerFile_WhenBothFilesExistAndOriginalItemSpecIsNewer()
{
// Arrange - simulate incremental build scenario where source file (OriginalItemSpec)
// is newer than destination file (RelatedAsset) because the copy hasn't happened yet.
// This is the scenario that causes SRI integrity failures in Blazor WASM (issue #65271).
var destinationFile = Path.Combine(_testDirectory, "wwwroot", "_framework", "dotnet.js");
var sourceFile = Path.Combine(_testDirectory, "obj", "Debug", "net11.0", "dotnet.js");

Directory.CreateDirectory(Path.GetDirectoryName(destinationFile));
Directory.CreateDirectory(Path.GetDirectoryName(sourceFile));

// Create destination file first (older)
File.WriteAllText(destinationFile, "// old content with stale fingerprints");
var oldTime = DateTime.UtcNow.AddMinutes(-5);
File.SetLastWriteTimeUtc(destinationFile, oldTime);

// Create source file second (newer) - this simulates a rebuild
File.WriteAllText(sourceFile, "// new content with updated fingerprints");
File.SetLastWriteTimeUtc(sourceFile, DateTime.UtcNow);

var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "dotnet.js.gz"));
assetToCompress.SetMetadata("RelatedAsset", destinationFile);
assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", sourceFile);

// Act
var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath);

// Assert - should use the NEWER source file, not the stale destination
result.Should().BeTrue();
fullPath.Should().Be(sourceFile);
fullPath.Should().NotBe(destinationFile);
_errorMessages.Should().BeEmpty();
_logMessages.Should().Contain(m => m.Contains("newer"));
}

[Fact]
public void TryFindInputFilePath_UsesRelatedAsset_WhenBothFilesExistButRelatedAssetIsNewer()
{
// Arrange - when destination file is newer (normal case after copy), use it
var destinationFile = Path.Combine(_testDirectory, "wwwroot", "_framework", "script.js");
var sourceFile = Path.Combine(_testDirectory, "obj", "Debug", "script.js");

Directory.CreateDirectory(Path.GetDirectoryName(destinationFile));
Directory.CreateDirectory(Path.GetDirectoryName(sourceFile));

// Create source file first (older)
File.WriteAllText(sourceFile, "// source content");
var oldTime = DateTime.UtcNow.AddMinutes(-5);
File.SetLastWriteTimeUtc(sourceFile, oldTime);

// Create destination file second (newer) - this simulates normal post-copy state
File.WriteAllText(destinationFile, "// destination content");
File.SetLastWriteTimeUtc(destinationFile, DateTime.UtcNow);

var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "script.js.gz"));
assetToCompress.SetMetadata("RelatedAsset", destinationFile);
assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", sourceFile);

// Act
var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath);

// Assert - should use destination file since it's newer
result.Should().BeTrue();
fullPath.Should().Be(destinationFile);
_errorMessages.Should().BeEmpty();
}

[Fact]
public void TryFindInputFilePath_UsesRelatedAsset_WhenBothPathsPointToSameFile()
{
// Arrange - when both paths point to the same file (case-insensitive),
// don't bother with timestamp comparison
var assetToCompress = new TaskItem("test.js.gz");
assetToCompress.SetMetadata("RelatedAsset", _testFilePath);
assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", _testFilePath.ToUpperInvariant());

// Act
var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath);

// Assert
result.Should().BeTrue();
fullPath.Should().Be(_testFilePath);
_errorMessages.Should().BeEmpty();
// Should NOT log the "newer" message since paths are the same
_logMessages.Should().NotContain(m => m.Contains("newer"));
}
}
Loading