Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private void SendBatchAsStreamingUpdate(in RenderBatch renderBatch, TextWriter w
writer.Write("</template>");
}

writer.Write("</blazor-ssr>");
writer.Write("<blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
writer.Write(_ssrFramingCommentMarkup);
}
}
Expand Down Expand Up @@ -173,14 +173,14 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,

writer.Write("<blazor-ssr><template type=\"error\">");
writer.Write(HtmlEncoder.Default.Encode(message));
writer.Write("</template></blazor-ssr>");
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
}

private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl)
{
writer.Write("<blazor-ssr><template type=\"redirection\">");
writer.Write(HtmlEncoder.Default.Encode(destinationUrl));
writer.Write("</template></blazor-ssr>");
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
}

protected override void WriteComponentHtml(int componentId, TextWriter output)
Expand Down
12 changes: 6 additions & 6 deletions src/Components/Endpoints/test/RazorComponentResultTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public async Task PerformsStreamingRendering()
tcs.SetResult();
await completionTask;
Assert.Equal(
"<!--bl:X-->Loading task status: WaitingForActivation<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loading task status: RanToCompletion</template></blazor-ssr>",
"<!--bl:X-->Loading task status: WaitingForActivation<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loading task status: RanToCompletion</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(GetStringContent(responseBody)));
}

Expand All @@ -141,7 +141,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAComponentRen
tcs.SetResult();
await completionTask;
Assert.Equal(
"<!--bl:X-->Loading...<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loaded</template></blazor-ssr>",
"<!--bl:X-->Loading...<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loaded</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(GetStringContent(responseBody)));
}

Expand Down Expand Up @@ -172,7 +172,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAnAncestorAls
tcs.SetResult();
await completionTask;
Assert.Equal(
$"{expectedInitialHtml}<blazor-ssr><template blazor-component-id=\"X\">[LoadingTask: RanToCompletion]\n<!--bl:X-->[Child render: 2]\n<!--/bl:X--></template></blazor-ssr>",
$"{expectedInitialHtml}<blazor-ssr><template blazor-component-id=\"X\">[LoadingTask: RanToCompletion]\n<!--bl:X-->[Child render: 2]\n<!--/bl:X--></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(GetStringContent(responseBody)));
}

Expand Down Expand Up @@ -266,7 +266,7 @@ public async Task OnNavigationAfterResponseStarted_WithStreamingOn_EmitsCommand(

// Assert
Assert.Equal(
$"<!--bl:X-->Some output\n<!--/bl:X--><blazor-ssr><template type=\"redirection\">https://test/somewhere/else</template></blazor-ssr>",
$"<!--bl:X-->Some output\n<!--/bl:X--><blazor-ssr><template type=\"redirection\">https://test/somewhere/else</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(GetStringContent(responseBody)));
}

Expand Down Expand Up @@ -352,7 +352,7 @@ public async Task StreamingRendering_IsOffByDefault_AndCanBeEnabledForSubtree()
await testContext.Quiescence;
html = GetStringContent(testContext.ResponseBody);
Assert.EndsWith(
"<blazor-ssr><template blazor-component-id=\"X\">Loaded</template></blazor-ssr>",
"<blazor-ssr><template blazor-component-id=\"X\">Loaded</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(html));
}

Expand Down Expand Up @@ -385,7 +385,7 @@ public async Task StreamingRendering_CanBeDisabledForSubtree()
await testContext.Quiescence;
html = GetStringContent(testContext.ResponseBody);
Assert.EndsWith(
"<blazor-ssr><template blazor-component-id=\"X\">Loaded</template></blazor-ssr>",
"<blazor-ssr><template blazor-component-id=\"X\">Loaded</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>",
MaskComponentIds(html));
}

Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

65 changes: 31 additions & 34 deletions src/Components/Web.JS/src/Rendering/StreamingRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,50 +16,47 @@ export function attachStreamingRenderingListener(options: SsrStartOptions | unde
enableDomPreservation = false;
}

customElements.define('blazor-ssr', BlazorStreamingUpdate);
// By the time <blazor-ssr-end> is in the DOM, we know all the preceding content within the same <blazor-ssr> is also there,
// so it's time to process it. We can't simply listen for <blazor-ssr>, because connectedCallback may fire before its content
// is present, and even listening for a later slotchange event doesn't work because the presence of <script> elements in the
// content can cause slotchange to fire before the rest of the content is added.
customElements.define('blazor-ssr-end', BlazorStreamingUpdate);
}

class BlazorStreamingUpdate extends HTMLElement {
connectedCallback() {
// Synchronously remove this from the DOM to minimize our chance of affecting anything else
this.parentNode?.removeChild(this);
const blazorSsrElement = this.parentNode!;

// The <blazor-ssr> element might not yet be populated since connectedCallback runs before
// the child markup is parsed. The most immediate way to get a notification when the child
// markup is added is to define a slot.
const shadowRoot = this.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadowRoot.appendChild(slot);
// Synchronously remove this from the DOM to minimize our chance of affecting anything else
blazorSsrElement.parentNode?.removeChild(blazorSsrElement);

// When this element receives content, if it's <template blazor-component-id="...">...</template>,
// insert the template content into the DOM
slot.addEventListener('slotchange', _ => {
this.childNodes.forEach(node => {
if (node instanceof HTMLTemplateElement) {
const componentId = node.getAttribute('blazor-component-id');
if (componentId) {
insertStreamingContentIntoDocument(componentId, node.content);
} else {
switch (node.getAttribute('type')) {
case 'redirection':
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
const destinationUrl = node.content.textContent!;
if (isWithinBaseUriSpace(destinationUrl)) {
history.replaceState(null, '', destinationUrl);
performEnhancedPageLoad(destinationUrl);
} else {
location.replace(destinationUrl);
}
break;
case 'error':
// This is kind of brutal but matches what happens without progressive enhancement
replaceDocumentWithPlainText(node.content.textContent || 'Error');
break;
}
blazorSsrElement.childNodes.forEach(node => {
if (node instanceof HTMLTemplateElement) {
const componentId = node.getAttribute('blazor-component-id');
if (componentId) {
insertStreamingContentIntoDocument(componentId, node.content);
} else {
switch (node.getAttribute('type')) {
case 'redirection':
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
const destinationUrl = node.content.textContent!;
if (isWithinBaseUriSpace(destinationUrl)) {
history.replaceState(null, '', destinationUrl);
performEnhancedPageLoad(destinationUrl);
} else {
location.replace(destinationUrl);
}
break;
case 'error':
// This is kind of brutal but matches what happens without progressive enhancement
replaceDocumentWithPlainText(node.content.textContent || 'Error');
break;
}
}
});
}
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public partial class StaticHtmlRenderer
string.Empty,
typeof(FormMappingContext));

private static readonly HtmlEncoder _htmlEncoder = HtmlEncoder.Default;
private static readonly TextEncoder _javaScriptEncoder = JavaScriptEncoder.Default;
private TextEncoder _htmlEncoder = HtmlEncoder.Default;
private string? _closestSelectValueAsString;

/// <summary>
Expand Down Expand Up @@ -138,6 +139,10 @@ private int RenderElement(int componentId, TextWriter output, ArrayRange<RenderT
_htmlEncoder.Encode(output, capturedValueAttribute);
afterElement = position + frame.ElementSubtreeLength; // Skip descendants
}
else if (string.Equals(frame.ElementNameField, "script", StringComparison.OrdinalIgnoreCase))
{
afterElement = RenderScriptElementChildren(componentId, output, frames, afterAttributes, remainingElements);
}
else
{
afterElement = RenderChildren(componentId, output, frames, afterAttributes, remainingElements);
Expand Down Expand Up @@ -173,6 +178,25 @@ private int RenderElement(int componentId, TextWriter output, ArrayRange<RenderT
}
}

private int RenderScriptElementChildren(int componentId, TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
{
// Inside a <script> context, AddContent calls should result in the text being
// JavaScript encoded rather than HTML encoded. It's not that we recommend inserting
// user-supplied content inside a <script> block, but that if someone does, we
// want the encoding style to match the context for correctness and safety. This is
// also consistent with .cshtml's treatment of <script>.
var originalEncoder = _htmlEncoder;
try
{
_htmlEncoder = _javaScriptEncoder;
return RenderChildren(componentId, output, frames, position, maxElements);
}
finally
{
_htmlEncoder = originalEncoder;
}
}

private void RenderHiddenFieldForNamedSubmitEvent(int componentId, TextWriter output, ArrayRange<RenderTreeFrame> frames, int namedEventFramePosition)
{
// Strictly speaking we could just emit the hidden input unconditionally, but since we currently
Expand Down Expand Up @@ -245,7 +269,7 @@ private static bool TryFindEnclosingElementFrame(ArrayRange<RenderTreeFrame> fra
return false;
}

private static int RenderAttributes(
private int RenderAttributes(
TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements, bool includeValueAttribute, out string? capturedValueAttribute)
{
capturedValueAttribute = null;
Expand Down
41 changes: 41 additions & 0 deletions src/Components/Web/test/HtmlRendering/HtmlRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,47 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
});
}

[Fact]
public async Task RenderComponentAsync_CanRenderScriptTag_WithJavaScriptEncodedContent()
{
// This is equivalent to Razor markup similar to:
//
// <script>
// alert('Hello, @name!');
// </script>
// And now with HTML encoding: @name
//
// Currently some extra linebreaks are needed to work around a Razor compiler issue (otherwise
// everything is treated as content to be encoded) but once https://github.com/dotnet/razor/issues/9204
// is fixed, the above should be correct.

// Arrange
var name = "Person with special chars like ' \" </script>";
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "script");
rtb.AddMarkupContent(1, "\n alert('Hello, ");
rtb.AddContent(2, name);
rtb.AddMarkupContent(3, "!');\n");
rtb.CloseElement();
rtb.AddMarkupContent(4, "\nAnd now with HTML encoding: ");
rtb.AddContent(5, name);
})).BuildServiceProvider();

var htmlRenderer = GetHtmlRenderer(serviceProvider);
await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
// Act
var result = await htmlRenderer.RenderComponentAsync<TestComponent>();

// Assert
Assert.Equal(@"<script>
alert('Hello, Person with special chars like \u0027 \u0022 \u003C/script\u003E!');
</script>
And now with HTML encoding: Person with special chars like &#x27; &quot; &lt;/script&gt;".Replace("\r", ""), result.ToHtmlString());
});
}

[Fact]
public async Task RenderComponentAsync_IgnoresNamedEvents()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ public void RefreshCanFallBackOnFullPageReload(string renderMode)

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText($"Interactive component navigation ({renderMode})")).Click();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);
((IJavaScriptExecutor)Browser).ExecuteScript("sessionStorage.setItem('suppress-enhanced-navigation', 'true')");

EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, true, skipNavigation: true);
Browser.Navigate().Refresh();
Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;

public static class EnhancedNavigationTestUtil
{
public static void SuppressEnhancedNavigation<TServerFixture>(ServerTestBase<TServerFixture> fixture, bool shouldSuppress, bool skipNavigation = false)
where TServerFixture : ServerFixture
{
if (shouldSuppress)
{
var browser = fixture.Browser;

if (!skipNavigation)
{
// Normally we need to navigate here first otherwise the browser isn't on the correct origin to access
// localStorage. But some tests are already in the right place and need to avoid extra navigation.
fixture.Navigate($"{fixture.ServerPathBase}/");
browser.Equal("Hello", () => browser.Exists(By.TagName("h1")).Text);
}

((IJavaScriptExecutor)browser).ExecuteScript("sessionStorage.setItem('suppress-enhanced-navigation', 'true')");
}
}
}
Loading