Skip to content
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

How to handle reloading pages created with POST #6600

Open
jakearchibald opened this issue Apr 21, 2021 · 12 comments
Open

How to handle reloading pages created with POST #6600

jakearchibald opened this issue Apr 21, 2021 · 12 comments

Comments

@jakearchibald
Copy link
Contributor

jakearchibald commented Apr 21, 2021

This came up as part of #6315, but it's previously been discussed:

I've been testing a few cases here:

Refreshing a page submitted with POST

  1. https://iframe-session-history.glitch.me/.
  2. Press the top "navigate via POST" button (not one associated with an iframe).
  3. Press browser refresh button.

Chrome, Firefox, Safari: Shows a prompt. Navigate unless prompt cancelled.

location.reload() on a page submitted with POST

  1. https://iframe-session-history.glitch.me/.
  2. Press the top "navigate via POST" button (not one associated with an iframe).
  3. Press "reload" button.

Chrome: No prompt, just resends POST.
Firefox: Shows a prompt. Navigate unless prompt cancelled.
Safari: Reloads as GET.

This came up in #3215. The current spec seems to support what Safari is currently doing, but the current spec doesn't really cover this stuff properly.

Showing a prompt might be weird, especially if it appears out of nowhere.

Safari's behavior seems safest, especially as the caller of location.reload() doesn't know if the reload will be POST or not.

Refreshing a page submitted with POST, after hash change

  1. https://iframe-session-history.glitch.me/.
  2. Press the top "navigate via POST" button (not one associated with an iframe).
  3. Press 'change hash'.
  4. Press browser refresh button.

Chrome, Firefox, Safari: Shows a prompt. Navigate unless prompt cancelled.

Refreshing a page submitted with POST, after pushState changes URL

  1. https://iframe-session-history.glitch.me/.
  2. Press the top "navigate via POST" button (not one associated with an iframe).
  3. Press 'pushState'.
  4. Press browser refresh button.

Chrome, Firefox: Navigates to new URL using GET.
Safari: Shows prompt. Unless cancelled, navigates to new URL using POST.

Safari's behavior of sending the POST data to a different URL is almost certainly wrong. Unless anyone disagrees, I'll spec what Firefox & Chrome do.

Traversing to top-level page previously sent with POST

  1. https://iframe-session-history.glitch.me/.
  2. Press "Set cookie: give all responses no-store".
  3. Press the top "navigate via POST" button (not one associated with an iframe).
  4. Press browser back button.
  5. Press browser forward button.

Chrome, Firefox: Navigation takes you an 'error' page of sorts that invites you to refresh the page. Refreshing shows a prompt.
Safari: Prompt is shown when forward is pressed. Cancelling this prompt abandons the traversal.

Either of these seems ok, but I prefer the Chrome/Firefox behavior, and it's the one I'd like to spec. What Safari does here has complications when it comes to iframes, and the Chrome/Firefox behavior feels more consistent.

Traversing history in a way that navigates two iframes to pages previously sent with POST

  1. https://iframe-session-history.glitch.me/.
  2. Press "Set cookie: give all responses no-store".
  3. iframe-1: Navigate via POST.
  4. iframe-2: Navigate via POST.
  5. iframe-1: Navigate to 'one'.
  6. iframe-2: Navigate to 'one'.
  7. Navigate back two history steps at once (via back button or go(-2)).

Chrome: Iframes show a 'sad tab' icon, and nothing else.
Firefox: Iframes show a 'error' page of sorts that invites you to refresh the page. Clicking this button refreshes the top-level, not the iframe.
Safari: Shows a prompt. If the prompt is cancelled, the traversal is cancelled. If the prompt is accepted, only one of the iframes navigates.

The Safari behavior is broken here. The way refresh works in Firefox seems a bit weird. Although the error Chrome shows is vague, it seems best.

Traversing back to a page that contains two iframes previously sent with POST

  1. https://iframe-session-history.glitch.me/.
  2. Press "Set cookie: give all responses no-store".
  3. Refresh page.
  4. iframe-1: Navigate via POST.
  5. iframe-2: Navigate via POST.
  6. "Navigate to same origin page".
  7. Press browser back button.

Chrome, Firefox: As above.
Safari: Iframes fail to load.

In this case, the Safari behavior seems closer to Chrome/Firefox.

What to spec

Manually refreshing a page where the top-level was submitted via POST should show a prompt. I'm not sure about location.reload(). I think it should either downgrade to GET, or show a prompt.

Using pushState means reloads of that document will be GET, even if the pushState doesn't change the URL, or only the hash of the URL changes.

Changing the hash of the URL by other means doesn't change anything in terms of refreshes. Refreshes will still show a prompt and use POST.

Traversing to a page previously fetched with POST will show an error page. If it's top-level, the error page can invite the user to refresh, which will prompt and re-POST. If it's nested, the error is not recoverable.

How to spec it

#6600 (comment)

@annevk
Copy link
Member

annevk commented Apr 21, 2021

This is great, thanks @jakearchibald!

I think location.reload() always doing GET would be great. (It would be nice if we could get away with that in general, but that's probably going too far.) Note that doing this requires adjusting this text:

In the case of non-idempotent methods (e.g., HTTP POST), the user agent should prompt the user to confirm the operation first

That text should probably be limited to UI-initiated reloads.

I think a nested error document inviting a full-page refresh is fine as a) it's UI and b) nested documents are an implementation detail users shouldn't have to understand.

@annevk
Copy link
Member

annevk commented Apr 21, 2021

As to how to specify it, I think we only need a "she request body" member that is null or a byte sequence. FormData is no good as application/x-www-form-urlencoded (or even text/plain) can appear in the request body as well. And only with POST would it be non-null as only POST has a request body. (Alternative names and data structures might work here, e.g., FormData is probably better in practice for multipart/form-data as you don't have to keep the serialization of all the files in memory, though that would mean that a reload might fail if the disk started failing in between. Not sure anyone wants to test that edge case though.)

@jakearchibald
Copy link
Contributor Author

I think a nested error document inviting a full-page refresh is fine as a) it's UI and b) nested documents are an implementation detail users shouldn't have to understand.

Yeah, that's fair. The message doesn't suggest it will resubmit data, so it's fine.

FormData is no good as application/x-www-form-urlencoded (or even text/plain) can appear in the request body as well.

Ah, yes. I messed that up in my "how to spec it" in a number of ways. The data we've got:

  • HTTP method, GET or POST
  • The request body
  • The content-type

We can't use FormData etc since it's a platform object, but it needs to outlive any given global. So, what about the following on the document state:

  • body. Null, or a list of name-value pairs.
  • content type. Which is null, "application/x-www-form-urlencoded", "multipart/form-data", or "text/plain".

If body is null, then the request is GET, otherwise it's POST. An empty list could be used for POST requests without a body.

@annevk
Copy link
Member

annevk commented Apr 21, 2021

I was going to say that sounds good, but then I realized that would mean you'd have to run https://html.spec.whatwg.org/#submit-body before passing it to navigate. And then I realized that we probably want the multipart/form-data boundary string to stay consistent. Although maybe we don't care?

@jakearchibald
Copy link
Contributor Author

Two options:

Store the body as an abstract key/value format, similar to FormData's underlying structure:

  • ✅ Can reference original files on disk, so it doesn't duplicate content.
  • ⚠️ What if that file is no longer accessible? What if it changes? (Although I guess we use whatever solution File already uses).
  • ⚠️ multipart/form-data boundary changes.

Store the body as a byte sequence:

  • ✅ No changes to multipart/form-data boundary.
  • ⚠️ Implies keeping content in memory, or duplicating content on disk.

I'm not sure what's best. Maybe the latter, but with a note saying UAs can optimise by gathering the data from multiple places on demand, as long as the result is the same. And if that isn't possible (due to drive unexpectedly disconnected) then… network error?

@jakearchibald jakearchibald self-assigned this Apr 21, 2021
@annevk
Copy link
Member

annevk commented Apr 21, 2021

With @andreubotella's work in https://github.com/andreubotella/multipart-form-data we can actually store the boundary, which might well be what implementations do. So the latter is probably best (including the note and network error idea) and we can refactor later?

@jakearchibald
Copy link
Contributor Author

jakearchibald commented Apr 21, 2021

Refreshing a page submitted with POST

Chrome, Firefox, Safari: Shows a prompt. Navigate unless prompt cancelled.

In case anyone starts questioning their sanity, like I have been, Chrome does not show a prompt if devtools is open.

https://bugs.chromium.org/p/chromium/issues/detail?id=1201204

@jakearchibald
Copy link
Contributor Author

jakearchibald commented Apr 21, 2021

Another test:

Refreshing a page submitted with POST, after document.open()

  1. https://iframe-session-history.glitch.me/.
  2. Press the top "navigate via POST" button (not one associated with an iframe).
  3. Press 'document.open()'.
  4. Press browser refresh button.

Chrome, Firefox: Refreshes the page using GET.
Safari: Shows prompt. Unless cancelled, refreshes the page using POST.

What Chrome & Firefox are doing here is kinda spec'd.

Step 12.3 of the document open steps calls URL and history update steps, which has some hand-waving in 3.4 about making the history entry a GET.

replaceState also hits this step, although pushState doesn't.

It seems pretty obvious to me that pushState and replaceState should downgrade the history entry to GET, but I can't figure out why document.open() should do the same thing. Any ideas @annevk?

@annevk
Copy link
Member

annevk commented Apr 21, 2021

The less user-facing prompts the better, so I wouldn't worry about it. (I suspect it's because document.open() originally would create a new document of sorts so it makes sense that gets a new kind of history entry.)

@jakearchibald
Copy link
Contributor Author

I noodled on this a bit in #6315.

A session history entry has a document state (which is shared between history entries that should share the same document), which has a resource which is a null, a srcdoc resource, or a POST resource.

pushState, replaceState, and document.open() go through URL and history update steps, which removes any POST resource in step 5.

@domenic
Copy link
Member

domenic commented May 4, 2021

I found an interesting HTML comment in the source:

   <!-- It appears that document.reload() always uses GET and does not, e.g., re-POST. Thus the
   difference between using the document's URL here, and "the same resource as that Document" below
   in the user-triggered reload section. -->

domenic added a commit that referenced this issue May 4, 2021
Closes #6556. In particular, reverts document.open() to only update the document's URL, and not the session history entry's URL, like it did before ae7cf0c. Now that they can mismatch, we need to audit the cases where this might be important, which leads to the following changes:

* Changes location.reload() to reload the current session history entry's URL, instead of the document's URL. This ensures that post-document.open() reload behavior is aligned with WebKit and Gecko, as tested by https://wpt.fyi/results/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/reload.window.html.

* Changes history.pushState()/history.replaceState() with no URL argument to default to the document's URL, instead of the current session history's URL. This ensures that post-document.open() pushState()/replaceState() behavior is aligned with all engines, as tested by web-platform-tests/wpt#28826.

This also modernizes and makes a bit more precise the location.reload() method steps. The user-initiated reload steps remain vague; #6600 will tackle those.
@jakearchibald
Copy link
Contributor Author

Hah, I wonder when that changed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

No branches or pull requests

3 participants