Allow set_page_config after callbacks
diff --git a/e2e/scripts/st_set_page_config.py b/e2e/scripts/st_set_page_config.py
index e7c992df9681..c08122f76c9a 100644
--- a/e2e/scripts/st_set_page_config.py
+++ b/e2e/scripts/st_set_page_config.py
@@ -22,3 +22,41 @@
 )
 st.sidebar.button("Sidebar!")
 st.markdown("Main!")
+
+
+def show_balloons():
+    st.balloons()
+
+
+st.button("Balloons", on_click=show_balloons)
+
+
+def double_set_page_config():
+    st.set_page_config(
+        page_title="Change 1",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+    st.set_page_config(
+        page_title="Change 2",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+
+st.button("Double Set Page Config", on_click=double_set_page_config)
+
+
+def single_set_page_config():
+    st.set_page_config(
+        page_title="Change 3",
+        page_icon=":shark:",
+        layout="wide",
+        initial_sidebar_state="collapsed",
+    )
+
+
+st.button("Single Set Page Config", on_click=single_set_page_config)
diff --git a/e2e/specs/st_set_page_config.spec.js b/e2e/specs/st_set_page_config.spec.js
index 0d1e775bccbe..ad285737d93f 100644
--- a/e2e/specs/st_set_page_config.spec.js
+++ b/e2e/specs/st_set_page_config.spec.js
@@ -51,4 +51,47 @@ describe("st.set_page_config", () => {
       "wide-mode"
     );
   });
+
+  describe("double-setting set_page_config", () => {
+    beforeEach(() => {
+      // Rerun the script to ensure a fresh slate.
+      // This is done by typing r on the page
+      cy.get("body").type("r");
+      // Ensure the rerun completes
+      cy.wait(1000);
+    });
+
+    it("should not display an error when st.set_page_config is used after an st.* command in a callback", () => {
+      cy.get(".stButton button")
+        .eq(1)
+        .click();
+
+      cy.get(".stException").should("not.exist");
+      cy.title().should("eq", "Heya, world?");
+    });
+
+    it("should display an error when st.set_page_config is called multiple times in a callback", () => {
+      cy.get(".stButton button")
+        .eq(2)
+        .click();
+
+      cy.get(".stException")
+        .contains("set_page_config() can only be called once per app")
+        .should("exist");
+      // Ensure that the first set_page_config worked
+      cy.title().should("eq", "Change 1");
+    });
+
+    it("should display an error when st.set_page_config is called after being called in a callback", () => {
+      cy.get(".stButton button")
+        .eq(3)
+        .click();
+
+      cy.get(".stException")
+        .contains("set_page_config() can only be called once per app")
+        .should("exist");
+      // Ensure that the first set_page_config worked
+      cy.title().should("eq", "Change 3");
+    });
+  });
 });
diff --git a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png
index e8a72c1db00f..8bfaff6eddd6 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png and b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode-dark.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png
index 12267b69a0ee..402e75a7904c 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png and b/frontend/cypress/snapshots/linux/2x/st_set_page_config.spec.js/wide-mode.snap.png differ
diff --git a/lib/streamlit/report_thread.py b/lib/streamlit/report_thread.py
index 1f3e65d86b48..66d679c6c3b8 100644
--- a/lib/streamlit/report_thread.py
+++ b/lib/streamlit/report_thread.py
@@ -73,6 +73,7 @@ def __init__(
         self.uploaded_file_mgr = uploaded_file_mgr
         # set_page_config is allowed at most once, as the very first st.command
         self._set_page_config_allowed = True
+        self._has_script_started = False
         # Stack of DGs used for the with block. The current one is at the end.
         self.dg_stack: List["streamlit.delta_generator.DeltaGenerator"] = []
 
@@ -86,6 +87,10 @@ def reset(self, query_string: str = "") -> None:
         self.query_string = query_string
         # Permit set_page_config when the ReportContext is reused on a rerun
         self._set_page_config_allowed = True
+        self._has_script_started = False
+
+    def on_script_start(self) -> None:
+        self._has_script_started = True
 
     def enqueue(self, msg: ForwardMsg) -> None:
         if msg.HasField("page_config_changed") and not self._set_page_config_allowed:
@@ -96,7 +101,12 @@ def enqueue(self, msg: ForwardMsg) -> None:
                 + "(https://docs.streamlit.io/en/stable/api.html#streamlit.set_page_config)."
             )
 
-        if msg.HasField("delta") or msg.HasField("page_config_changed"):
+        # We want to disallow set_page config if one of the following occurs:
+        # - set_page_config was called on this message
+        # - The script has already started and a different st call occurs (a delta)
+        if msg.HasField("page_config_changed") or (
+            msg.HasField("delta") and self._has_script_started
+        ):
             self._set_page_config_allowed = False
 
         self._enqueue(msg)
diff --git a/lib/streamlit/script_runner.py b/lib/streamlit/script_runner.py
index 11b4409c6ce6..5a0a8c3f4a33 100644
--- a/lib/streamlit/script_runner.py
+++ b/lib/streamlit/script_runner.py
@@ -346,6 +346,7 @@ def _run_script(self, rerun_data):
 
                     self._session_state.call_callbacks()
 
+                ctx.on_script_start()
                 exec(code, module.__dict__)
 
         except RerunException as e:
diff --git a/lib/tests/streamlit/report_context_test.py b/lib/tests/streamlit/report_context_test.py
index 840a7a7a6fbe..4d708c521afb 100644
--- a/lib/tests/streamlit/report_context_test.py
+++ b/lib/tests/streamlit/report_context_test.py
@@ -42,7 +42,8 @@ def test_set_page_config_immutable(self):
             ctx.enqueue(msg)
 
     def test_set_page_config_first(self):
-        """st.set_page_config must be called before other st commands"""
+        """st.set_page_config must be called before other st commands
+        when the script has been marked as started"""
 
         fake_enqueue = lambda msg: None
         ctx = ReportContext(
@@ -53,6 +54,8 @@ def test_set_page_config_first(self):
             UploadedFileManager(),
         )
 
+        ctx.on_script_start()
+
         markdown_msg = ForwardMsg()
         markdown_msg.delta.new_element.markdown.body = "foo"
 
@@ -63,6 +66,29 @@ def test_set_page_config_first(self):
         with self.assertRaises(StreamlitAPIException):
             ctx.enqueue(msg)
 
+    def test_disallow_set_page_config_twice(self):
+        """st.set_page_config cannot be called twice"""
+
+        fake_enqueue = lambda msg: None
+        ctx = ReportContext(
+            "TestSessionID",
+            fake_enqueue,
+            "",
+            SessionState(),
+            UploadedFileManager(),
+        )
+
+        ctx.on_script_start()
+
+        msg = ForwardMsg()
+        msg.page_config_changed.title = "foo"
+        ctx.enqueue(msg)
+
+        with self.assertRaises(StreamlitAPIException):
+            same_msg = ForwardMsg()
+            same_msg.page_config_changed.title = "bar"
+            ctx.enqueue(same_msg)
+
     def test_set_page_config_reset(self):
         """st.set_page_config should be allowed after a rerun"""
 
@@ -75,12 +101,15 @@ def test_set_page_config_reset(self):
             UploadedFileManager(),
         )
 
+        ctx.on_script_start()
+
         msg = ForwardMsg()
         msg.page_config_changed.title = "foo"
 
         ctx.enqueue(msg)
         ctx.reset()
         try:
+            ctx.on_script_start()
             ctx.enqueue(msg)
         except StreamlitAPIException:
             self.fail("set_page_config should have succeeded after reset!")

fixes #3410 

We only allow set_page_config to be called once per script run, and we do not allow any other st calls to execute prior to it. Callbacks changed things, and st calls are now allowed to run before set_page_config.

This change ensures the following:
- callbacks can call st commands without throwing an exception for `set_page_config`
- `set_page_config` remains the first st command allowed when the script runs
- In any case, we prevent `set_page_config` from being called twice.