Skip to content

Commit 0b632fa

Browse files
Progressively enhanced form posts (#48939)
1 parent f8732f5 commit 0b632fa

22 files changed

+222
-121
lines changed

src/Components/Web.JS/dist/Release/blazor.web.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Rendering/StreamingRendering.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
import { SsrStartOptions } from "../Platform/SsrStartOptions";
5-
import { performEnhancedPageLoad } from "../Services/NavigationEnhancement";
5+
import { performEnhancedPageLoad, replaceDocumentWithPlainText } from "../Services/NavigationEnhancement";
66
import { isWithinBaseUriSpace } from "../Services/NavigationUtils";
77
import { synchronizeDomContent } from "./DomMerging/DomSync";
88

@@ -51,11 +51,7 @@ class BlazorStreamingUpdate extends HTMLElement {
5151
break;
5252
case 'error':
5353
// This is kind of brutal but matches what happens without progressive enhancement
54-
document.documentElement.textContent = node.content.textContent;
55-
const docStyle = document.documentElement.style;
56-
docStyle.fontFamily = 'consolas, monospace';
57-
docStyle.whiteSpace = 'pre-wrap';
58-
docStyle.padding = '1rem';
54+
replaceDocumentWithPlainText(node.content.textContent || 'Error');
5955
break;
6056
}
6157
}

src/Components/Web.JS/src/Services/NavigationEnhancement.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,18 @@ let onDocumentUpdatedCallback: Function = () => {};
3333

3434
export function attachProgressivelyEnhancedNavigationListener(onDocumentUpdated: Function) {
3535
onDocumentUpdatedCallback = onDocumentUpdated;
36-
document.body.addEventListener('click', onBodyClicked);
36+
document.addEventListener('click', onDocumentClick);
37+
document.addEventListener('submit', onDocumentSubmit);
3738
window.addEventListener('popstate', onPopState);
3839
}
3940

4041
export function detachProgressivelyEnhancedNavigationListener() {
41-
document.body.removeEventListener('click', onBodyClicked);
42+
document.removeEventListener('click', onDocumentClick);
43+
document.removeEventListener('submit', onDocumentSubmit);
4244
window.removeEventListener('popstate', onPopState);
4345
}
4446

45-
function onBodyClicked(event: MouseEvent) {
47+
function onDocumentClick(event: MouseEvent) {
4648
if (hasInteractiveRouter()) {
4749
return;
4850
}
@@ -61,18 +63,52 @@ function onPopState(state: PopStateEvent) {
6163
performEnhancedPageLoad(location.href);
6264
}
6365

64-
export async function performEnhancedPageLoad(internalDestinationHref: string) {
66+
function onDocumentSubmit(event: SubmitEvent) {
67+
if (hasInteractiveRouter() || event.defaultPrevented) {
68+
return;
69+
}
70+
71+
// We need to be careful not to interfere with existing interactive forms. As it happens, EventDelegator always
72+
// uses a capturing event handler for 'submit', so it will necessarily run before this handler, and so we won't
73+
// even get here if there's an interactive submit (because it will have set defaultPrevented which we check above).
74+
// However if we ever change that, we would need to change this code to integrate properly with EventDelegator
75+
// to make sure this handler only ever runs after interactive handlers.
76+
const formElem = event.target;
77+
if (formElem instanceof HTMLFormElement) {
78+
event.preventDefault();
79+
80+
const url = new URL(formElem.action);
81+
const fetchOptions: RequestInit = { method: formElem.method };
82+
const formData = new FormData(formElem);
83+
84+
// Replicate the normal behavior of appending the submitter name/value to the form data
85+
const submitter = event.submitter as HTMLButtonElement;
86+
if (submitter && submitter.name) {
87+
formData.append(submitter.name, submitter.value);
88+
}
89+
90+
if (fetchOptions.method === 'get') { // method is always returned as lowercase
91+
url.search = new URLSearchParams(formData as any).toString();
92+
} else {
93+
fetchOptions.body = formData;
94+
}
95+
96+
performEnhancedPageLoad(url.toString(), fetchOptions);
97+
}
98+
}
99+
100+
export async function performEnhancedPageLoad(internalDestinationHref: string, fetchOptions?: RequestInit) {
65101
// First, stop any preceding enhanced page load
66102
currentEnhancedNavigationAbortController?.abort();
67103

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

84-
if (response.headers.get('content-type')?.startsWith('text/html')) {
120+
const responseContentType = response.headers.get('content-type');
121+
if (responseContentType?.startsWith('text/html')) {
85122
// For HTML responses, regardless of the status code, display it
86123
const parsedHtml = new DOMParser().parseFromString(initialContent, 'text/html');
87124
synchronizeDomContent(document, parsedHtml);
125+
} else if (responseContentType?.startsWith('text/')) {
126+
// For any other text-based content, we'll just display it, because that's what
127+
// would happen if this was a non-enhanced request.
128+
replaceDocumentWithPlainText(initialContent);
88129
} else if ((response.status < 200 || response.status >= 300) && !initialContent) {
89130
// For any non-success response that has no content at all, make up our own error UI
90-
document.documentElement.innerHTML = `Error: ${response.status} ${response.statusText}`;
131+
replaceDocumentWithPlainText(`Error: ${response.status} ${response.statusText}`);
91132
} else {
92133
// For any other response, it's not HTML and we don't know what to do. It might be plain text,
93-
// or an image, or something else. So fall back on a full reload, even though that means we
94-
// have to request the content a second time.
95-
// The ? trick here is the same workaround as described in #10839, and without it, the user
96-
// would not be able to use the back button afterwards.
97-
history.replaceState(null, '', internalDestinationHref + '?');
98-
location.replace(internalDestinationHref);
134+
// or an image, or something else.
135+
if (!fetchOptions?.method || fetchOptions.method === 'get') {
136+
// If it's a get request, we'll trust that it's idempotent and cheap enough to request
137+
// a second time, so we can fall back on a full reload.
138+
// The ? trick here is the same workaround as described in #10839, and without it, the user
139+
// would not be able to use the back button afterwards.
140+
history.replaceState(null, '', internalDestinationHref + '?');
141+
location.replace(internalDestinationHref);
142+
} else {
143+
// For non-get requests, we can't safely re-request, so just treat it as an error
144+
replaceDocumentWithPlainText(`Error: ${fetchOptions.method} request to ${internalDestinationHref} returned non-HTML content of type ${responseContentType || 'unspecified'}.`);
145+
}
99146
}
100147
},
101148
(streamingElementMarkup) => {
@@ -178,6 +225,14 @@ async function getResponsePartsWithFraming(responsePromise: Promise<Response>, a
178225
}
179226
}
180227

228+
export function replaceDocumentWithPlainText(text: string) {
229+
document.documentElement.textContent = text;
230+
const docStyle = document.documentElement.style;
231+
docStyle.fontFamily = 'consolas, monospace';
232+
docStyle.whiteSpace = 'pre-wrap';
233+
docStyle.padding = '1rem';
234+
}
235+
181236
function splitStream(frameBoundaryMarker: string) {
182237
let buffer = '';
183238

src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public override Task InitializeAsync()
3030
[Fact]
3131
public void CanNavigateToAnotherPageWhilePreservingCommonDOMElements()
3232
{
33-
Navigate(ServerPathBase);
33+
Navigate($"{ServerPathBase}/nav");
3434

3535
var h1Elem = Browser.Exists(By.TagName("h1"));
3636
Browser.Equal("Hello", () => h1Elem.Text);
@@ -48,31 +48,31 @@ public void CanNavigateToAnotherPageWhilePreservingCommonDOMElements()
4848
[Fact]
4949
public void CanNavigateToAnHtmlPageWithAnErrorStatus()
5050
{
51-
Navigate(ServerPathBase);
51+
Navigate($"{ServerPathBase}/nav");
5252
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Error page with 404 content")).Click();
5353
Browser.Equal("404", () => Browser.Exists(By.TagName("h1")).Text);
5454
}
5555

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

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

7272
[Fact]
7373
public void ScrollsToHashWithContentAddedAsynchronously()
7474
{
75-
Navigate(ServerPathBase);
75+
Navigate($"{ServerPathBase}/nav");
7676
Browser.Exists(By.TagName("nav")).FindElement(By.LinkText("Scroll to hash")).Click();
7777
Assert.Equal(0, BrowserScrollY);
7878

@@ -84,7 +84,7 @@ public void ScrollsToHashWithContentAddedAsynchronously()
8484
[Fact]
8585
public void CanFollowSynchronousRedirection()
8686
{
87-
Navigate(ServerPathBase);
87+
Navigate($"{ServerPathBase}/nav");
8888

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

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

107107
[Fact]
108108
public void CanFollowAsynchronousRedirectionWhileStreaming()
109109
{
110-
Navigate(ServerPathBase);
110+
Navigate($"{ServerPathBase}/nav");
111111

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

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

127127
[Fact]
128128
public void CanFollowSynchronousExternalRedirection()
129129
{
130-
Navigate(ServerPathBase);
130+
Navigate($"{ServerPathBase}/nav");
131131
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
132132

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

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

0 commit comments

Comments
 (0)