Closed Bug 1847798 Opened 1 year ago Closed 5 months ago

Reuse of client side UI state causing hydration issue with primed cache reloads (session restore restoring the disabled state of the button)

Categories

(Firefox :: Session Restore, defect)

Firefox 116
defect

Tracking

()

RESOLVED DUPLICATE of bug 654072

People

(Reporter: matt, Unassigned)

Details

Attachments

(1 file)

Steps to reproduce:

  1. Save the following code to a server.mjs file. This creates the simplest possible React Server Side Rendering setup with hydration (uses CDN copies of react to avoid the need for a bundler). It renders a disabled button on the server and then after hydration on the client - it enables the button 2.5 seconds later.

  2. Run node server.mjs

  3. Load http://localhost:3000 and notice no errors in the console.

import http from "node:http";
import * as React from "react";
import * as ReactDOMServer from "react-dom/server";

function App() {
  let [disabled, setDisabled] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => setDisabled(false), 2500);
  }, []);

  return React.createElement(
    "html",
    null,
    React.createElement(
      "head",
      null,
      React.createElement(
        "style",
        null,
        "button { border: 2px solid green; } " +
          "button[disabled] { border: 2px solid red; }"
      )
    ),
    React.createElement(
      "body",
      null,
      React.createElement("button", { disabled: disabled }, "Hello Button"),
      React.createElement("script", {
        crossOrigin: "true",
        src: "https://unpkg.com/react@18.2.0/umd/react.development.js",
      }),
      React.createElement("script", {
        crossOrigin: "true",
        src: "https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js",
      }),
      React.createElement("script", {
        async: "async",
        dangerouslySetInnerHTML: {
          __html:
            App.toString() +
            "\n" +
            "ReactDOM.hydrateRoot(document, React.createElement(App));",
        },
      })
    )
  );
}

let server = http.createServer((req, res) => {
  let doc = ReactDOMServer.renderToString(React.createElement(App));
  res.setHeader("Content-Type", "text/html");
  res.write("<!DOCTYPE html>" + doc);
  res.end();
});

server.listen(3000, () => {
  console.log("server listening on http://localhost:3000");
});

Actual results:

We would expect reloads (coming from the network or from the cache) to hydrate successfully with an initial client side "disabled" state.

Expected results:

Soft reloads from a primed cache after 2.5 seconds (and the button has been enabled client side) cause a hydration error.

  • hard reloads (holding shift key) before the 2.s mark are OK
  • hard reloads (holding shift key) after the 2.s mark are OK
  • soft reloads (with a primed cache) before the 2.s mark are OK
  • soft reloads (with a primed cache) after the 2.s mark cause the error

It appears FireFox is preserving some aspect of the client side DOM on soft reloads such that it thinks the initial state of the button is enabled. This causes an issue in React's hydration step because the server always sends a disabled button.

I'm using Firefox 116.0.2 64 bit on Mac OSX Ventura 13.5.

The Bugbug bot thinks this bug should belong to the 'Core::DOM: Core & HTML' component, and is moving the bug to that component. Please correct in case you think the bot is wrong.

Component: Untriaged → DOM: Core & HTML
Product: Firefox → Core

Could you attach the result which Firefox received? (it must be not required to attach all files which are linked from the HTML file.)

Flags: needinfo?(matt)

By "result", are you referring to the output of the HTTP server? It's easiest perform React hydration from a running HTTP server (node server.mjs) using renderToString since whitespace is relevant when it comes to hydration.

Here is the output of the server - you can save this directly into an index.html file, but you need to save it as-is and do not allow any formatting changes for it to work correctly:

<!DOCTYPE html><html><head><style>button { border: 2px solid green; } button[disabled] { border: 2px solid red; }</style></head><body><button disabled="">Hello Button</button><script crossorigin="true" src="https://unpkg.com/react@18.2.0/umd/react.development.js"></script><script crossorigin="true" src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js"></script><script async="">function App() {
  let [disabled, setDisabled] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => setDisabled(false), 2500);
  }, []);

  return React.createElement(
    "html",
    null,
    React.createElement(
      "head",
      null,
      React.createElement(
        "style",
        null,
        "button { border: 2px solid green; } " +
          "button[disabled] { border: 2px solid red; }"
      )
    ),
    React.createElement(
      "body",
      null,
      React.createElement("button", { disabled: disabled }, "Hello Button"),
      React.createElement("script", {
        crossOrigin: "true",
        src: "https://unpkg.com/react@18.2.0/umd/react.development.js",
      }),
      React.createElement("script", {
        crossOrigin: "true",
        src: "https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js",
      }),
      React.createElement("script", {
        async: "async",
        dangerouslySetInnerHTML: {
          __html:
            App.toString() +
            "\n" +
            "ReactDOM.hydrateRoot(document, React.createElement(App));",
        },
      })
    )
  );
}
ReactDOM.hydrateRoot(document, React.createElement(App));</script></body></html>

When you do a hard reload, the button renders disabled initially (red border) and then hydrates to an enabled button 2.5 seconds later (green border). There are also no errors from the client-side hydration.

When you do a soft reload after that 2.5 second window, the button incorrectly loads enabled from the start (even though the cached index.html file has it in a disabled state). This is what causes the hydration issue - the mismatch between what the server sent (disabled) and the client initial state (enabled). The hydration error is attached in a screenshot:

Warning: Prop `disabled` did not match. Server: "null" Client: "true"
button
body
html
App@http://localhost:8080/:2:39
Flags: needinfo?(matt)

Thank you. It seems that we intentionally save the disabled attribute and restore it (bug 277724).
https://searchfox.org/mozilla-central/rev/4647149548182ba7eb5c447d5a93abfca2f6acd6/dom/html/HTMLButtonElement.cpp#327,329,331,368,374-377

farre: Do you know something about this?

Flags: needinfo?(afarre)

This is session restore restoring the disabled state of the button. Moving this to the Session Restore component.

Component: DOM: Core & HTML → Session Restore
Flags: needinfo?(afarre)
Product: Core → Firefox
QA Whiteboard: qa-not-actionable

The severity field is not set for this bug.
:dao, could you have a look please?

For more information, please visit BugBot documentation.

Flags: needinfo?(dao+bmo)
Flags: needinfo?(dao+bmo)
Summary: Reuse of client side UI state causing hydration issue with primed cache reloads → Reuse of client side UI state causing hydration issue with primed cache reloads (session restore restoring the disabled state of the button)

Possible dupe? I have a distant memory of another bug with a similar issue to do with form state and session restore, but I can't find it now.

I'm not sure I follow which part is the bug here. Session restore is restoring a tab with some URL and putting the form back into the state we had saved. Then later that a form field on that page, script on the page is updating the field's disabled attribute. Which part is the bug - saving the wrong (or any) disabled state or allowing some script to update it later? When we save form state we end up with 2 different sources of truth: the form's state when the tab was closed, and the state as understood by the server. Either could be more recent or authoritative - depending on the circumstances. And I'm not sure how we would to resolve this.

Is that roughly correct or am I missing something?

Setting autocomplete="off" looks like a workaround to prevent this behavior - seems to work for the testcase above even though strictly that's not a valid attribute on a button.

See also https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled:

Firefox will, unlike other browsers, persist the dynamic disabled state of a <button> across page loads. Use the autocomplete attribute to control this feature.

(In reply to Sam Foster [:sfoster] (he/him) from comment #7)

Possible dupe? I have a distant memory of another bug with a similar issue to do with form state and session restore, but I can't find it now.

Maybe this one? https://bugzilla.mozilla.org/show_bug.cgi?id=654072

Status: UNCONFIRMED → RESOLVED
Closed: 5 months ago
Duplicate of bug: 654072
Resolution: --- → DUPLICATE
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: