Skip to content

Progressively enhanced form posts #48939

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

Merged
merged 10 commits into from
Jun 21, 2023
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions src/Components/Web.JS/src/Rendering/StreamingRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

import { SsrStartOptions } from "../Platform/SsrStartOptions";
import { performEnhancedPageLoad } from "../Services/NavigationEnhancement";
import { performEnhancedPageLoad, replaceDocumentWithPlainText } from "../Services/NavigationEnhancement";
import { isWithinBaseUriSpace } from "../Services/NavigationUtils";
import { synchronizeDomContent } from "./DomMerging/DomSync";

Expand Down Expand Up @@ -51,11 +51,7 @@ class BlazorStreamingUpdate extends HTMLElement {
break;
case 'error':
// This is kind of brutal but matches what happens without progressive enhancement
document.documentElement.textContent = node.content.textContent;
const docStyle = document.documentElement.style;
docStyle.fontFamily = 'consolas, monospace';
docStyle.whiteSpace = 'pre-wrap';
docStyle.padding = '1rem';
replaceDocumentWithPlainText(node.content.textContent || 'Error');
break;
}
}
Expand Down
83 changes: 69 additions & 14 deletions src/Components/Web.JS/src/Services/NavigationEnhancement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,18 @@ let onDocumentUpdatedCallback: Function = () => {};

export function attachProgressivelyEnhancedNavigationListener(onDocumentUpdated: Function) {
onDocumentUpdatedCallback = onDocumentUpdated;
document.body.addEventListener('click', onBodyClicked);
document.addEventListener('click', onDocumentClick);
document.addEventListener('submit', onDocumentSubmit);
window.addEventListener('popstate', onPopState);
}

export function detachProgressivelyEnhancedNavigationListener() {
document.body.removeEventListener('click', onBodyClicked);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('submit', onDocumentSubmit);
window.removeEventListener('popstate', onPopState);
}

function onBodyClicked(event: MouseEvent) {
function onDocumentClick(event: MouseEvent) {
if (hasInteractiveRouter()) {
return;
}
Expand All @@ -61,18 +63,52 @@ function onPopState(state: PopStateEvent) {
performEnhancedPageLoad(location.href);
}

export async function performEnhancedPageLoad(internalDestinationHref: string) {
function onDocumentSubmit(event: SubmitEvent) {
if (hasInteractiveRouter() || event.defaultPrevented) {
return;
}

// We need to be careful not to interfere with existing interactive forms. As it happens, EventDelegator always
// uses a capturing event handler for 'submit', so it will necessarily run before this handler, and so we won't
// even get here if there's an interactive submit (because it will have set defaultPrevented which we check above).
// However if we ever change that, we would need to change this code to integrate properly with EventDelegator
// to make sure this handler only ever runs after interactive handlers.
const formElem = event.target;
if (formElem instanceof HTMLFormElement) {
event.preventDefault();

const url = new URL(formElem.action);
const fetchOptions: RequestInit = { method: formElem.method };
const formData = new FormData(formElem);

// Replicate the normal behavior of appending the submitter name/value to the form data
const submitter = event.submitter as HTMLButtonElement;
if (submitter && submitter.name) {
formData.append(submitter.name, submitter.value);
}

if (fetchOptions.method === 'get') { // method is always returned as lowercase
url.search = new URLSearchParams(formData as any).toString();
} else {
fetchOptions.body = formData;
}

performEnhancedPageLoad(url.toString(), fetchOptions);
}
}

export async function performEnhancedPageLoad(internalDestinationHref: string, fetchOptions?: RequestInit) {
// First, stop any preceding enhanced page load
currentEnhancedNavigationAbortController?.abort();

// Now request the new page via fetch, and a special header that tells the server we want it to inject
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
currentEnhancedNavigationAbortController = new AbortController();
const abortSignal = currentEnhancedNavigationAbortController.signal;
const responsePromise = fetch(internalDestinationHref, {
const responsePromise = fetch(internalDestinationHref, Object.assign({
signal: abortSignal,
headers: { 'blazor-enhanced-nav': 'on' },
});
}, fetchOptions));
await getResponsePartsWithFraming(responsePromise, abortSignal,
(response, initialContent) => {
if (response.redirected) {
Expand All @@ -81,21 +117,32 @@ export async function performEnhancedPageLoad(internalDestinationHref: string) {
internalDestinationHref = response.url;
}

if (response.headers.get('content-type')?.startsWith('text/html')) {
const responseContentType = response.headers.get('content-type');
if (responseContentType?.startsWith('text/html')) {
// For HTML responses, regardless of the status code, display it
const parsedHtml = new DOMParser().parseFromString(initialContent, 'text/html');
synchronizeDomContent(document, parsedHtml);
} else if (responseContentType?.startsWith('text/')) {
// For any other text-based content, we'll just display it, because that's what
// would happen if this was a non-enhanced request.
replaceDocumentWithPlainText(initialContent);
} else if ((response.status < 200 || response.status >= 300) && !initialContent) {
// For any non-success response that has no content at all, make up our own error UI
document.documentElement.innerHTML = `Error: ${response.status} ${response.statusText}`;
replaceDocumentWithPlainText(`Error: ${response.status} ${response.statusText}`);
} else {
// For any other response, it's not HTML and we don't know what to do. It might be plain text,
// or an image, or something else. So fall back on a full reload, even though that means we
// have to request the content a second time.
// The ? trick here is the same workaround as described in #10839, and without it, the user
// would not be able to use the back button afterwards.
history.replaceState(null, '', internalDestinationHref + '?');
location.replace(internalDestinationHref);
// or an image, or something else.
if (!fetchOptions?.method || fetchOptions.method === 'get') {
// If it's a get request, we'll trust that it's idempotent and cheap enough to request
// a second time, so we can fall back on a full reload.
// The ? trick here is the same workaround as described in #10839, and without it, the user
// would not be able to use the back button afterwards.
history.replaceState(null, '', internalDestinationHref + '?');
location.replace(internalDestinationHref);
} else {
// For non-get requests, we can't safely re-request, so just treat it as an error
replaceDocumentWithPlainText(`Error: ${fetchOptions.method} request to ${internalDestinationHref} returned non-HTML content of type ${responseContentType || 'unspecified'}.`);
}
}
},
(streamingElementMarkup) => {
Expand Down Expand Up @@ -178,6 +225,14 @@ async function getResponsePartsWithFraming(responsePromise: Promise<Response>, a
}
}

export function replaceDocumentWithPlainText(text: string) {
document.documentElement.textContent = text;
const docStyle = document.documentElement.style;
docStyle.fontFamily = 'consolas, monospace';
docStyle.whiteSpace = 'pre-wrap';
docStyle.padding = '1rem';
}

function splitStream(frameBoundaryMarker: string) {
let buffer = '';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public override Task InitializeAsync()
[Fact]
public void CanNavigateToAnotherPageWhilePreservingCommonDOMElements()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");

var h1Elem = Browser.Exists(By.TagName("h1"));
Browser.Equal("Hello", () => h1Elem.Text);
Expand All @@ -48,31 +48,31 @@ public void CanNavigateToAnotherPageWhilePreservingCommonDOMElements()
[Fact]
public void CanNavigateToAnHtmlPageWithAnErrorStatus()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Error page with 404 content")).Click();
Browser.Equal("404", () => Browser.Exists(By.TagName("h1")).Text);
}

[Fact]
public void DisplaysStatusCodeIfResponseIsErrorWithNoContent()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Error page with no content")).Click();
Browser.Equal("Error: 404 Not Found", () => Browser.Exists(By.TagName("body")).Text);
Browser.Equal("Error: 404 Not Found", () => Browser.Exists(By.TagName("html")).Text);
}

[Fact]
public void CanNavigateToNonHtmlResponse()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Non-HTML page")).Click();
Browser.Equal("Hello, this is plain text", () => Browser.Exists(By.TagName("body")).Text);
Browser.Equal("Hello, this is plain text", () => Browser.Exists(By.TagName("html")).Text);
}

[Fact]
public void ScrollsToHashWithContentAddedAsynchronously()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Scroll to hash")).Click();
Assert.Equal(0, BrowserScrollY);

Expand All @@ -84,7 +84,7 @@ public void ScrollsToHashWithContentAddedAsynchronously()
[Fact]
public void CanFollowSynchronousRedirection()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");

var h1Elem = Browser.Exists(By.TagName("h1"));
Browser.Equal("Hello", () => h1Elem.Text);
Expand All @@ -96,18 +96,18 @@ public void CanFollowSynchronousRedirection()
// here and instead use the same protocol it uses for external redirections.
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Redirect")).Click();
Browser.Equal("Scroll to hash", () => h1Elem.Text);
Assert.EndsWith("/subdir/scroll-to-hash", Browser.Url);
Assert.EndsWith("/subdir/nav/scroll-to-hash", Browser.Url);

// See that 'back' takes you to the place from before the redirection
Browser.Navigate().Back();
Browser.Equal("Hello", () => h1Elem.Text);
Assert.EndsWith("/subdir", Browser.Url);
Assert.EndsWith("/subdir/nav", Browser.Url);
}

[Fact]
public void CanFollowAsynchronousRedirectionWhileStreaming()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");

var h1Elem = Browser.Exists(By.TagName("h1"));
Browser.Equal("Hello", () => h1Elem.Text);
Expand All @@ -116,18 +116,18 @@ public void CanFollowAsynchronousRedirectionWhileStreaming()
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Redirect while streaming")).Click();
Browser.Equal("Scroll to hash", () => h1Elem.Text);
Browser.True(() => BrowserScrollY > 500);
Assert.EndsWith("/subdir/scroll-to-hash#some-content", Browser.Url);
Assert.EndsWith("/subdir/nav/scroll-to-hash#some-content", Browser.Url);

// See that 'back' takes you to the place from before the redirection
Browser.Navigate().Back();
Browser.Equal("Hello", () => h1Elem.Text);
Assert.EndsWith("/subdir", Browser.Url);
Assert.EndsWith("/subdir/nav", Browser.Url);
}

[Fact]
public void CanFollowSynchronousExternalRedirection()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Redirect external")).Click();
Expand All @@ -137,7 +137,7 @@ public void CanFollowSynchronousExternalRedirection()
[Fact]
public void CanFollowAsynchronousExternalRedirectionWhileStreaming()
{
Navigate(ServerPathBase);
Navigate($"{ServerPathBase}/nav");
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);

Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Redirect external while streaming")).Click();
Expand Down
Loading