@@ -33,16 +33,18 @@ let onDocumentUpdatedCallback: Function = () => {};
33
33
34
34
export function attachProgressivelyEnhancedNavigationListener ( onDocumentUpdated : Function ) {
35
35
onDocumentUpdatedCallback = onDocumentUpdated ;
36
- document . body . addEventListener ( 'click' , onBodyClicked ) ;
36
+ document . addEventListener ( 'click' , onDocumentClick ) ;
37
+ document . addEventListener ( 'submit' , onDocumentSubmit ) ;
37
38
window . addEventListener ( 'popstate' , onPopState ) ;
38
39
}
39
40
40
41
export function detachProgressivelyEnhancedNavigationListener ( ) {
41
- document . body . removeEventListener ( 'click' , onBodyClicked ) ;
42
+ document . removeEventListener ( 'click' , onDocumentClick ) ;
43
+ document . removeEventListener ( 'submit' , onDocumentSubmit ) ;
42
44
window . removeEventListener ( 'popstate' , onPopState ) ;
43
45
}
44
46
45
- function onBodyClicked ( event : MouseEvent ) {
47
+ function onDocumentClick ( event : MouseEvent ) {
46
48
if ( hasInteractiveRouter ( ) ) {
47
49
return ;
48
50
}
@@ -61,18 +63,52 @@ function onPopState(state: PopStateEvent) {
61
63
performEnhancedPageLoad ( location . href ) ;
62
64
}
63
65
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 ) {
65
101
// First, stop any preceding enhanced page load
66
102
currentEnhancedNavigationAbortController ?. abort ( ) ;
67
103
68
104
// Now request the new page via fetch, and a special header that tells the server we want it to inject
69
105
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
70
106
currentEnhancedNavigationAbortController = new AbortController ( ) ;
71
107
const abortSignal = currentEnhancedNavigationAbortController . signal ;
72
- const responsePromise = fetch ( internalDestinationHref , {
108
+ const responsePromise = fetch ( internalDestinationHref , Object . assign ( {
73
109
signal : abortSignal ,
74
110
headers : { 'blazor-enhanced-nav' : 'on' } ,
75
- } ) ;
111
+ } , fetchOptions ) ) ;
76
112
await getResponsePartsWithFraming ( responsePromise , abortSignal ,
77
113
( response , initialContent ) => {
78
114
if ( response . redirected ) {
@@ -81,21 +117,32 @@ export async function performEnhancedPageLoad(internalDestinationHref: string) {
81
117
internalDestinationHref = response . url ;
82
118
}
83
119
84
- if ( response . headers . get ( 'content-type' ) ?. startsWith ( 'text/html' ) ) {
120
+ const responseContentType = response . headers . get ( 'content-type' ) ;
121
+ if ( responseContentType ?. startsWith ( 'text/html' ) ) {
85
122
// For HTML responses, regardless of the status code, display it
86
123
const parsedHtml = new DOMParser ( ) . parseFromString ( initialContent , 'text/html' ) ;
87
124
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 ) ;
88
129
} else if ( ( response . status < 200 || response . status >= 300 ) && ! initialContent ) {
89
130
// 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 } ` ) ;
91
132
} else {
92
133
// 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
+ }
99
146
}
100
147
} ,
101
148
( streamingElementMarkup ) => {
@@ -178,6 +225,14 @@ async function getResponsePartsWithFraming(responsePromise: Promise<Response>, a
178
225
}
179
226
}
180
227
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
+
181
236
function splitStream ( frameBoundaryMarker : string ) {
182
237
let buffer = '' ;
183
238
0 commit comments