From be54f6c6f8c6cdd414d90b1fe602b6f349f8ea02 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 9 Oct 2026 00:46:52 +0530 Subject: [PATCH 1/7] Fix stale on_load during websocket connection --- packages/reflex-base/news/+7487.bugfix.md | 1 + .../reflex_base/.templates/web/utils/state.js | 22 ++++- .../test_navigation_before_connect.py | 88 +++++++++++++++++++ tests/units/compiler/state_js.test.mjs | 17 ++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/reflex-base/news/+7487.bugfix.md create mode 100644 tests/integration/tests_playwright/test_navigation_before_connect.py diff --git a/packages/reflex-base/news/+7487.bugfix.md b/packages/reflex-base/news/+7487.bugfix.md new file mode 100644 index 00000000000..1ebdb87569e --- /dev/null +++ b/packages/reflex-base/news/+7487.bugfix.md @@ -0,0 +1 @@ +Use the browser's current route when building the websocket connection's initial hydration event, so navigation during connection setup cannot run the previous page's `on_load` handler or redirect. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 6916df56371..9f1310039eb 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -22,6 +22,8 @@ const EVENTURL = env.EVENT; // Socket event names (must match reflex_base/constants/event.py SocketEvent) const CLIENT_ERROR_EVENT = "client_error"; +const ON_LOAD_INTERNAL_EVENT = + "reflex___state____on_load_internal_state.on_load_internal"; // Client error types (must match reflex_base/constants/event.py ClientErrorType) const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing"; @@ -695,16 +697,18 @@ export const connect = async ( // Get backend URL object from the endpoint. const endpoint = getBackendURL(EVENTURL); const on_hydrated_queue = []; + let bootRoute; // The hydrate event rides in the socket.io CONNECT packet, so the backend // starts loading state as soon as the namespace connects instead of after // an extra round trip for the connect acknowledgement. The key is read by // the backend as CompileVars.CONNECT_AUTH_EVENT. - const bootAuth = (first) => { + const bootAuth = (first) => (callback) => { const boot_event = withRouterData(app.initialEvents(first)[0], params); + bootRoute = boot_event.router_data.asPath; // Instrumentation hook (installed by reflex-otel): may add a traceparent. window.__reflex_otel?.onEventSend?.(boot_event); - return { event: boot_event }; + callback({ event: boot_event }); }; // Create the socket. A new session's token is saved here, once the app has @@ -725,6 +729,8 @@ export const connect = async ( warmSocket ?? createSocket(endpoint, transports, session_token); warmSocket = null; cancelWarmup(); + // Socket.IO calls this when the namespace connection is sent, so route data + // reflects any navigation that happened while the transport was connecting. socket.current.auth = bootAuth(true); socket.current.wait_connect = !socket.current.connected; // Ensure undefined fields in events are sent as null instead of removed @@ -798,6 +804,18 @@ export const connect = async ( window.__reflex_otel?.onSocketConnect?.(); window.addEventListener("pagehide", pagehideHandler); window.addEventListener("beforeunload", disconnectTrigger); + // The connection auth event already loads its route. Discard navigation + // on_load events queued during the handshake only if that route is still + // current; otherwise the queued event is needed to load the newer route. + if (bootRoute === withRouterData({}, params).router_data.asPath) { + for (let i = event_queue.length - 1; i >= 0; i--) { + if ( + event_queue[i]?.name === `${app.state_name}.${ON_LOAD_INTERNAL_EVENT}` + ) { + event_queue.splice(i, 1); + } + } + } // Drain any initial events from the queue. while (event_queue.length > 0) { await processEvent(socket.current, navigate, params); diff --git a/tests/integration/tests_playwright/test_navigation_before_connect.py b/tests/integration/tests_playwright/test_navigation_before_connect.py new file mode 100644 index 00000000000..1a1faf11450 --- /dev/null +++ b/tests/integration/tests_playwright/test_navigation_before_connect.py @@ -0,0 +1,88 @@ +"""Regressions for navigation while the initial websocket is connecting.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, WebSocketRoute, expect + +from reflex.testing import AppHarness + + +def NavigationBeforeConnectApp(): + """App whose first route redirects if its stale on_load runs.""" + import reflex as rx + + class NavigationState(rx.State): + loads: list[str] = [] + + @rx.event + def slow_load(self): + self.loads.append("slow") + return rx.redirect("/hijack") + + @rx.event + def other_load(self): + self.loads.append("other") + + app = rx.App() + + def page_content(): + return rx.vstack( + rx.foreach(NavigationState.loads, lambda item: rx.text(item)), + rx.link("Other", href="/other", id="other-link"), + ) + + app.add_page(page_content, route="/slow", on_load=NavigationState.slow_load) + app.add_page(page_content, route="/other", on_load=NavigationState.other_load) + app.add_page(page_content, route="/hijack") + + +@pytest.fixture(scope="module") +def navigation_before_connect_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start the app in dev or production mode. + + Yields: + The running app harness. + """ + with app_harness_env.create( + root=tmp_path_factory.mktemp("navigation_before_connect"), + app_name=f"navigationbeforeconnect_{app_harness_env.__name__.lower()}", + app_source=NavigationBeforeConnectApp, + ) as harness: + yield harness + + +def test_navigation_before_socket_connect_uses_current_route( + navigation_before_connect_app: AppHarness, page: Page +): + """A delayed connect must not run the old page's on_load or redirect.""" + assert navigation_before_connect_app.frontend_url is not None + frontend_url = navigation_before_connect_app.frontend_url.rstrip("/") + sockets: list[WebSocketRoute] = [] + page_errors: list[str] = [] + page.on("pageerror", lambda error: page_errors.append(str(error))) + + def hold_socket(socket: WebSocketRoute) -> None: + sockets.append(socket) + + page.route_web_socket("**/_event/**", hold_socket) + + page.goto(f"{frontend_url}/slow") + expect(page.get_by_role("link", name="Other")).to_be_visible() + if not sockets: + with page.expect_websocket(timeout=60000): + page.wait_for_timeout(100) + assert len(sockets) == 1, page_errors + + page.get_by_role("link", name="Other").click() + expect(page).to_have_url(f"{frontend_url}/other") + + sockets[0].connect_to_server() + + expect(page.locator("body")).to_contain_text("other") + expect(page.get_by_text("other", exact=True)).to_have_count(1) + expect(page).to_have_url(f"{frontend_url}/other") + expect(page.locator("body")).not_to_contain_text("slow") diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 9efcc61472b..a83ae56a107 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -80,6 +80,11 @@ async function setup({ ]) { assert.ok(handlers.has(name), `missing handler: ${name}`); } + if (typeof this.auth === "function") { + this.auth((auth) => { + this.auth = auth; + }); + } this.namespaceConnects++; this.connected = true; handlers.get("connect")(); @@ -195,6 +200,18 @@ test("warm the transport without hydrating, then reuse it with every handler att assert.equal(app.socket.current.auth.event.router_data.pathname, "/page"); }); +test("connection auth uses the route current when the namespace connects", async () => { + const app = await setup(); + app.flush(); + app.window.location = new URL("http://localhost:3000/other?query=value"); + await app.connect(); + assert.equal(app.socket.current.auth.event.router_data.pathname, "/other"); + assert.equal( + app.socket.current.auth.event.router_data.asPath, + "/other?query=value", + ); +}); + test("a new session's token is saved only once the mounted app connects", async () => { const app = await setup({ storedToken: null }); app.flush(); From 54b6b33f8b71bf99e78f76d918d7073740b46486 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 9 Oct 2026 00:53:02 +0530 Subject: [PATCH 2/7] Test route changes during socket handshake --- tests/units/compiler/state_js.test.mjs | 28 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index a83ae56a107..fbb924c148a 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -18,6 +18,7 @@ async function setup({ disabled = false, hidden = false, storedToken = "original-token", + deferNamespaceConnect = false, } = {}) { const sockets = []; const microtasks = []; @@ -80,16 +81,19 @@ async function setup({ ]) { assert.ok(handlers.has(name), `missing handler: ${name}`); } - if (typeof this.auth === "function") { - this.auth((auth) => { - this.auth = auth; - }); - } this.namespaceConnects++; - this.connected = true; - handlers.get("connect")(); - handlers.get("new_token")("assigned-token"); - handlers.get("event")({ delta: { child: { count: 7 } } }); + this.finishNamespaceConnect = () => { + if (typeof this.auth === "function") { + this.auth((auth) => { + this.auth = auth; + }); + } + this.connected = true; + handlers.get("connect")(); + handlers.get("new_token")("assigned-token"); + handlers.get("event")({ delta: { child: { count: 7 } } }); + }; + if (!deferNamespaceConnect) this.finishNamespaceConnect(); }, disconnect() { this.disconnects++; @@ -201,10 +205,12 @@ test("warm the transport without hydrating, then reuse it with every handler att }); test("connection auth uses the route current when the namespace connects", async () => { - const app = await setup(); + const app = await setup({ deferNamespaceConnect: true }); app.flush(); - app.window.location = new URL("http://localhost:3000/other?query=value"); await app.connect(); + assert.equal(app.socket.current.namespaceConnects, 1); + app.window.location = new URL("http://localhost:3000/other?query=value"); + app.socket.current.finishNamespaceConnect(); assert.equal(app.socket.current.auth.event.router_data.pathname, "/other"); assert.equal( app.socket.current.auth.event.router_data.asPath, From 3d6beff2307319fbdc9a38c98f26b8ad29ecf243 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 22 Oct 2026 17:19:06 +0530 Subject: [PATCH 3/7] test: model Socket.IO auth callbacks --- .../reflex_base/client_state_updates.mjs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 4eabef315bf..190d8f1b2d7 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -44,6 +44,7 @@ async function createConnection( setItem: (key, value) => session.set(key, String(value)), }; const body = element("body"); + let authPayload; const socket = { connected: true, io: { @@ -54,6 +55,13 @@ async function createConnection( on: (name, handler) => handlers.set(name, handler), emit: (name, value) => sent.push({ name, value }), connect: () => { + if (typeof socket.auth === "function") { + socket.auth((auth) => { + authPayload = auth; + }); + } else { + authPayload = socket.auth; + } socket.connected = true; handlers.get("connect")(); }, @@ -114,6 +122,8 @@ async function createConnection( reloads, session, socket, + authPayload: () => authPayload, + readAuth: () => socket.auth((auth) => (authPayload = auth)), reports: () => sent.filter((event) => event.name === "client_error"), receive: (update) => handlers.get("event")(update), reconnect: () => handlers.get("connect")(), @@ -165,14 +175,15 @@ test("an unknown substate is reported once per connection", async (t) => { test("the state schema rides in the CONNECT auth on connect and reconnect", async (t) => { const c = await createConnection(t); - assert.equal(c.socket.auth.state_schema, FRONTEND_SCHEMA); - assert.equal(c.socket.auth.event.name, "test.hydrate"); - assert.deepEqual(c.socket.auth.event.payload.hashes, []); + c.readAuth(); + assert.equal(c.authPayload().state_schema, FRONTEND_SCHEMA); + assert.equal(c.authPayload().event.name, "test.hydrate"); + assert.deepEqual(c.authPayload().event.payload.hashes, []); c.reconnectSocket(); assert.equal(c.socket.connected, true); - assert.equal(c.socket.auth.state_schema, FRONTEND_SCHEMA); - assert.equal(c.socket.auth.event.payload.hashes, undefined); + assert.equal(c.authPayload().state_schema, FRONTEND_SCHEMA); + assert.equal(c.authPayload().event.payload.hashes, undefined); }); test("a mismatch in prod reloads once per cooldown, then shows a banner", async (t) => { From 00120951dd74d214cee5a8d92f527c97bb9d063e Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 23 Oct 2026 12:46:35 +0530 Subject: [PATCH 4/7] fix: address on_load handshake review feedback --- .../reflex_base/.templates/web/utils/state.js | 16 +++++---- .../src/reflex_base/compiler/templates.py | 3 ++ .../test_navigation_before_connect.py | 3 +- tests/units/compiler/state_js.test.mjs | 33 ++++++++++++++++--- tests/units/compiler/test_compiler.py | 6 ++++ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index a5125486d00..70b676eecc7 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -22,8 +22,6 @@ const EVENTURL = env.EVENT; // Socket event names (must match reflex_base/constants/event.py SocketEvent) const CLIENT_ERROR_EVENT = "client_error"; -const ON_LOAD_INTERNAL_EVENT = - "reflex___state____on_load_internal_state.on_load_internal"; const STATE_MISMATCH_EVENT = "state_mismatch"; // sessionStorage key with the time of the last automatic reload (must match @@ -721,6 +719,12 @@ const withRouterData = (event, params) => { return event; }; +const currentRouteKey = () => { + const loc = locationRef.current ?? window.location; + const liveLoc = env.MOUNT_TARGET ? loc : window.location; + return loc.pathname + (liveLoc.search ?? ""); +}; + /** * Send an event to the server via REST. * @param event The current event. @@ -928,7 +932,7 @@ export const connect = async ( // land on another backend version. const bootAuth = (first) => (callback) => { const boot_event = withRouterData(app.initialEvents(first)[0], params); - bootRoute = boot_event.router_data.asPath; + bootRoute = currentRouteKey(); recordSentStorageValues(boot_event); // Instrumentation hook (installed by reflex-otel): may add a traceparent. window.__reflex_otel?.onEventSend?.(boot_event); @@ -1072,11 +1076,9 @@ export const connect = async ( // The connection auth event already loads its route. Discard navigation // on_load events queued during the handshake only if that route is still // current; otherwise the queued event is needed to load the newer route. - if (bootRoute === withRouterData({}, params).router_data.asPath) { + if (bootRoute === currentRouteKey()) { for (let i = event_queue.length - 1; i >= 0; i--) { - if ( - event_queue[i]?.name === `${app.state_name}.${ON_LOAD_INTERNAL_EVENT}` - ) { + if (event_queue[i].name === app.on_load_internal_event) { event_queue.splice(i, 1); } } diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 38072d97b53..4899a9230ad 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -312,6 +312,7 @@ def context_template( state_str = ( rf""" export const state_name = "{state_name}" +export const on_load_internal_event = '{state_name}.{constants.CompileVars.ON_LOAD_INTERNAL}' export const exception_state_name = "{constants.CompileVars.FRONTEND_EXCEPTION_STATE_FULL}" @@ -366,6 +367,7 @@ def context_template( if state_name else """ export const state_name = undefined +export const on_load_internal_event = undefined export const exception_state_name = undefined @@ -426,6 +428,7 @@ def context_template( initialState, clientStorage, state_name, + on_load_internal_event, stateSchema, exception_state_name, onLoadInternalEvent, diff --git a/tests/integration/tests_playwright/test_navigation_before_connect.py b/tests/integration/tests_playwright/test_navigation_before_connect.py index 1a1faf11450..c1b96e39a3c 100644 --- a/tests/integration/tests_playwright/test_navigation_before_connect.py +++ b/tests/integration/tests_playwright/test_navigation_before_connect.py @@ -73,8 +73,7 @@ def hold_socket(socket: WebSocketRoute) -> None: page.goto(f"{frontend_url}/slow") expect(page.get_by_role("link", name="Other")).to_be_visible() if not sockets: - with page.expect_websocket(timeout=60000): - page.wait_for_timeout(100) + page.wait_for_event("websocket", timeout=60000) assert len(sockets) == 1, page_errors page.get_by_role("link", name="Other").click() diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index f85c68c6561..4eea6dd3e9a 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -34,6 +34,7 @@ async function setup({ const microtasks = []; const timers = new Map(); const firstHydrates = []; + const onLoadInternalEvent = `${stateName}.test_on_load_internal`; const updates = []; const consoleErrors = []; let connectErrors = []; @@ -203,6 +204,7 @@ async function setup({ }, initialState: stateful ? { root: {}, child: {} } : {}, onLoadInternalEvent() {}, + on_load_internal_event: onLoadInternalEvent, state_name: stateName, stateSchema: { version: "test" }, clientStorage, @@ -242,6 +244,7 @@ async function setup({ sockets, timers, firstHydrates, + onLoadInternalEvent, window, localStorage, socket, @@ -315,10 +318,7 @@ test("queued on_load is kept when navigation follows auth capture", async () => assert.equal(socket.auth.event.router_data.asPath, "/page?query=value"); app.window.location = new URL("http://localhost:3000/other?query=value"); - const onLoadEvent = { - name: `${stateName}.reflex___state____on_load_internal_state.on_load_internal`, - payload: {}, - }; + const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; await app.module.namespace.queueEvents( [onLoadEvent], app.socket, @@ -345,6 +345,31 @@ test("queued on_load is kept when navigation follows auth capture", async () => ]); }); +test( + "queued on_load is discarded when only the hash changes after auth capture", + async () => { + const stateName = "reflex___state____root"; + const app = await setup({ deferConnectAck: true, stateName }); + app.window.location = new URL("http://localhost:3000/other?query=value#top"); + app.flush(); + await app.connect(); + const socket = app.socket.current; + + app.window.location = new URL("http://localhost:3000/other?query=value"); + const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; + await app.module.namespace.queueEvents( + [onLoadEvent], + app.socket, + false, + () => {}, + { current: {} }, + ); + await socket.finishConnectAck(); + + assert.deepEqual(JSON.parse(JSON.stringify(socket.events)), []); + }, +); + test("the size check encodes a CONNECT packet once and leaves other packets alone", async () => { const app = await connectWith("websocket", {}, 1_000_000); const { encoder } = app.socket.current.io; diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index dc80f257cb8..5c42f69d418 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -722,13 +722,19 @@ class SchemaContextChild(SchemaContextRoot): schema = SchemaContextRoot._to_frontend_schema() assert f'export const stateSchema = "{schema}"' in code + on_load_internal_event = ( + f"{SchemaContextRoot.get_full_name()}.{constants.CompileVars.ON_LOAD_INTERNAL}" + ) + assert f"export const on_load_internal_event = '{on_load_internal_event}'" in code start = code.index("registerApp({") assert "stateSchema," in code[start : code.index("});", start)] + assert "on_load_internal_event," in code[start : code.index("});", start)] assert SchemaContextChild.get_full_name() in code # Without state there is nothing to compare, and nothing to connect with. _, code = compiler.compile_contexts(None, None) assert "export const stateSchema = null" in code + assert "export const on_load_internal_event = undefined" in code @pytest.mark.parametrize( From 858c8e98acc1c353f0101de945121e4a519e5f6a Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 23 Oct 2026 13:08:29 +0530 Subject: [PATCH 5/7] style: format on_load handshake test --- tests/units/compiler/state_js.test.mjs | 41 ++++++++++++-------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 4eea6dd3e9a..641c24891b9 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -345,30 +345,27 @@ test("queued on_load is kept when navigation follows auth capture", async () => ]); }); -test( - "queued on_load is discarded when only the hash changes after auth capture", - async () => { - const stateName = "reflex___state____root"; - const app = await setup({ deferConnectAck: true, stateName }); - app.window.location = new URL("http://localhost:3000/other?query=value#top"); - app.flush(); - await app.connect(); - const socket = app.socket.current; +test("queued on_load is discarded when only the hash changes after auth capture", async () => { + const stateName = "reflex___state____root"; + const app = await setup({ deferConnectAck: true, stateName }); + app.window.location = new URL("http://localhost:3000/other?query=value#top"); + app.flush(); + await app.connect(); + const socket = app.socket.current; - app.window.location = new URL("http://localhost:3000/other?query=value"); - const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; - await app.module.namespace.queueEvents( - [onLoadEvent], - app.socket, - false, - () => {}, - { current: {} }, - ); - await socket.finishConnectAck(); + app.window.location = new URL("http://localhost:3000/other?query=value"); + const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; + await app.module.namespace.queueEvents( + [onLoadEvent], + app.socket, + false, + () => {}, + { current: {} }, + ); + await socket.finishConnectAck(); - assert.deepEqual(JSON.parse(JSON.stringify(socket.events)), []); - }, -); + assert.deepEqual(JSON.parse(JSON.stringify(socket.events)), []); +}); test("the size check encodes a CONNECT packet once and leaves other packets alone", async () => { const app = await connectWith("websocket", {}, 1_000_000); From e7f77c562b5b8b237d3220eb4adf20f153e36570 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 20:10:25 +0500 Subject: [PATCH 6/7] fix: send one queued on_load when the route changes during the handshake Every queued on_load_internal loads the route current when it is sent, so keeping all of them ran the new page's on_load once per navigation made during the websocket handshake. Keep only the first one, in its queue position, when the route changed after the auth event was built. --- .../reflex_base/.templates/web/utils/state.js | 19 +++++++----- tests/units/compiler/state_js.test.mjs | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 70b676eecc7..86c2e98a609 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1073,16 +1073,19 @@ export const connect = async ( window.__reflex_otel?.onSocketConnect?.(); window.addEventListener("pagehide", pagehideHandler); window.addEventListener("beforeunload", disconnectTrigger); - // The connection auth event already loads its route. Discard navigation - // on_load events queued during the handshake only if that route is still - // current; otherwise the queued event is needed to load the newer route. - if (bootRoute === currentRouteKey()) { - for (let i = event_queue.length - 1; i >= 0; i--) { - if (event_queue[i].name === app.on_load_internal_event) { - event_queue.splice(i, 1); - } + // The connection auth event already loads its route, and every queued + // on_load loads the route current when it is sent. Keep the first queued + // on_load only if the route changed after the auth event was built. + let keepOnLoad = bootRoute !== currentRouteKey(); + let kept = 0; + for (const event of event_queue) { + if (event.name === app.on_load_internal_event) { + if (!keepOnLoad) continue; + keepOnLoad = false; } + event_queue[kept++] = event; } + event_queue.length = kept; // Drain any initial events from the queue. while (event_queue.length > 0) { await processEvent(socket.current, navigate, params); diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 641c24891b9..fd62c8a3fd8 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -345,6 +345,36 @@ test("queued on_load is kept when navigation follows auth capture", async () => ]); }); +test("queued on_loads for a newer route are sent once, in queue order", async () => { + const stateName = "reflex___state____root"; + const app = await setup({ deferConnectAck: true, stateName }); + app.flush(); + await app.connect(); + const socket = app.socket.current; + + app.window.location = new URL("http://localhost:3000/other?query=value"); + const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; + const userEvent = { name: `${stateName}.child.increment`, payload: {} }; + await app.module.namespace.queueEvents( + [{ ...onLoadEvent }, userEvent, { ...onLoadEvent }], + app.socket, + false, + () => {}, + { current: {} }, + ); + await socket.finishConnectAck(); + + const router_data = { + pathname: "/other", + asPath: "/other?query=value", + query: { query: "value" }, + }; + assert.deepEqual(JSON.parse(JSON.stringify(socket.events)), [ + { name: "event", event: { ...onLoadEvent, router_data } }, + { name: "event", event: { ...userEvent, router_data } }, + ]); +}); + test("queued on_load is discarded when only the hash changes after auth capture", async () => { const stateName = "reflex___state____root"; const app = await setup({ deferConnectAck: true, stateName }); From 4c61fa36c820dbf6e1dd4688776d38b81acd91b5 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 20:47:23 +0500 Subject: [PATCH 7/7] fix: keep the last queued on_load after the handshake Each navigation queues the browser storage it read, then its on_load. Keeping the first queued on_load ran the page's handlers before a later navigation's storage update. Keep the last one instead, so the handlers see the newest storage values. --- .../reflex_base/.templates/web/utils/state.js | 16 +++++++-------- tests/units/compiler/state_js.test.mjs | 20 +++++++++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 86c2e98a609..04ba2cefbdf 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1074,18 +1074,18 @@ export const connect = async ( window.addEventListener("pagehide", pagehideHandler); window.addEventListener("beforeunload", disconnectTrigger); // The connection auth event already loads its route, and every queued - // on_load loads the route current when it is sent. Keep the first queued - // on_load only if the route changed after the auth event was built. + // on_load loads the route current when it is sent. Keep the last queued + // on_load, after every navigation's storage update, only if the route + // changed after the auth event was built. let keepOnLoad = bootRoute !== currentRouteKey(); - let kept = 0; - for (const event of event_queue) { - if (event.name === app.on_load_internal_event) { - if (!keepOnLoad) continue; + for (let i = event_queue.length - 1; i >= 0; i--) { + if (event_queue[i].name !== app.on_load_internal_event) continue; + if (keepOnLoad) { keepOnLoad = false; + } else { + event_queue.splice(i, 1); } - event_queue[kept++] = event; } - event_queue.length = kept; // Drain any initial events from the queue. while (event_queue.length > 0) { await processEvent(socket.current, navigate, params); diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index fd62c8a3fd8..e3e6dd1486b 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -345,18 +345,29 @@ test("queued on_load is kept when navigation follows auth capture", async () => ]); }); -test("queued on_loads for a newer route are sent once, in queue order", async () => { +test("queued on_loads for a newer route are sent once, after the last storage update", async () => { const stateName = "reflex___state____root"; const app = await setup({ deferConnectAck: true, stateName }); app.flush(); await app.connect(); const socket = app.socket.current; + // Each navigation queues the browser storage it read, then its on_load. app.window.location = new URL("http://localhost:3000/other?query=value"); const onLoadEvent = { name: app.onLoadInternalEvent, payload: {} }; - const userEvent = { name: `${stateName}.child.increment`, payload: {} }; + const storageEvent = (value) => ({ + name: `${stateName}.update_vars_internal_state.update_vars_internal`, + payload: { vars: { "root.token": value } }, + }); + await app.module.namespace.queueEvents( + [storageEvent("first"), { ...onLoadEvent }], + app.socket, + false, + () => {}, + { current: {} }, + ); await app.module.namespace.queueEvents( - [{ ...onLoadEvent }, userEvent, { ...onLoadEvent }], + [storageEvent("second"), { ...onLoadEvent }], app.socket, false, () => {}, @@ -370,8 +381,9 @@ test("queued on_loads for a newer route are sent once, in queue order", async () query: { query: "value" }, }; assert.deepEqual(JSON.parse(JSON.stringify(socket.events)), [ + { name: "event", event: { ...storageEvent("first"), router_data } }, + { name: "event", event: { ...storageEvent("second"), router_data } }, { name: "event", event: { ...onLoadEvent, router_data } }, - { name: "event", event: { ...userEvent, router_data } }, ]); });