Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Report a hydrate message that exceeds the server's message size limit, with its size and the limit, instead of sending it and retrying. Reduce browser storage data and reload to recover.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const NEW_VERSION_BANNER_ID = "reflex-new-version-banner";
// Client error types (must match reflex_base/constants/event.py ClientErrorType)
const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing";
const ERROR_TYPE_STATE_UPDATE = "state_update_processing_error";
const ERROR_TYPE_HYDRATION_PAYLOAD_TOO_LARGE = "hydration_payload_too_large";

// These hostnames indicate that the backend and frontend are reachable via the same domain.
const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"];
Expand Down Expand Up @@ -196,6 +197,21 @@ const createSocket = (endpoint, transports, token) =>
reconnection: false,
});

/**
* Measure encoded text the way the Engine.IO server limits a frame.
* @param frame The encoded text.
* @param transport The transport that carries it.
* @returns The code points for a websocket text frame, or the UTF-8 bytes for a polling body.
*/
const frameSize = (frame, transport) => {
if (transport !== "websocket") {
return new TextEncoder().encode(frame).byteLength;
}
let size = 0;
for (const _ of frame) size++;
return size;
};

let warmSocket = null;
let cancelWarmup = () => {};
let socketStarted = false;
Expand Down Expand Up @@ -937,6 +953,36 @@ export const connect = async (
socket.current.wait_connect = !socket.current.connected;
// Ensure undefined fields in events are sent as null instead of removed
socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v);
// The server drops a transport whose frame exceeds the limit its handshake
// announced, so a CONNECT packet (type 0) that carries an oversized hydrate
// is reported instead of sent. The page stays offline until browser storage
// shrinks and it reloads: wait_connect stays set, so reconnect() does not
// rebuild the hydrate.
const { encoder } = socket.current.io;
const encode = encoder.encode.bind(encoder);
encoder.encode = (packet) => {
const encoded = encode(packet);
if (packet.type !== 0) {
return encoded;
}
// socket.io-client is pinned; test_hydration_storage.py covers these internals.
const { engine } = socket.current.io;
const transport = engine.transport.name;
// The Engine.IO message type ("4") prefixes the encoded packet.
const size = 1 + frameSize(encoded[0], transport);
if (size > engine._maxPayload) {
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
const unit = transport === "websocket" ? "characters" : "bytes";
const error = new Error(
`Hydration payload (${size} ${unit}) exceeds the server limit (${engine._maxPayload} ${unit}). Reduce browser storage data and reload.`,
);
error.code = ERROR_TYPE_HYDRATION_PAYLOAD_TOO_LARGE;
console.error(error.message);
setConnectErrors((errors) => [...errors.slice(-9), error]);
socket.current.disconnect();
return [];
}
return encoded;
};
socket.current.io.decoder.tryParse = (str) => {
try {
return parseJson(str);
Expand Down
5 changes: 4 additions & 1 deletion packages/reflex-base/src/reflex_base/constants/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,16 @@ def __str__(self) -> str:


class ClientErrorType(SimpleNamespace):
"""Error types reported by the frontend via the client_error socket event.
"""Error types reported by the frontend.

The backend receives them via the client_error socket event, and the
connection banners read them from the connection errors.
Must match the ERROR_TYPE_* constants in .templates/web/utils/state.js.
"""

DISPATCH_MISSING = "dispatch_function_missing"
STATE_UPDATE = "state_update_processing_error"
HYDRATION_PAYLOAD_TOO_LARGE = "hydration_payload_too_large"


class EventTriggers(SimpleNamespace):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The default connection toast shows a hydration size error at once and keeps it open until the user closes it.
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
_js_expr="(connectErrors.length >= 2)", _var_data=connect_error_var_data
).to(BooleanVar)

has_hydration_payload_error = Var(
_js_expr=f"connectErrors.some((error) => error.code === '{constants.ClientErrorType.HYDRATION_PAYLOAD_TOO_LARGE}')",
_var_data=connect_error_var_data,
).to(BooleanVar)


class WebsocketTargetURL(Var):
"""A component that renders the websocket target URL."""
Expand Down Expand Up @@ -96,6 +101,8 @@ def add_hooks(self) -> list[str | Var]:
The hooks for the connection toaster.
"""
toast_id = "websocket-error"
# Its own id, so a pending dismiss of the connection toast cannot remove it.
hydration_toast_id = "hydration-payload-error"
target_url = WebsocketTargetURL.create()
props = ToastProps(
description=LiteralVar.create(
Expand Down Expand Up @@ -159,7 +166,9 @@ def add_hooks(self) -> list[str | Var]:
Var(
_js_expr=f"""
() => {{
if ({has_too_many_connection_errors!s}) {{
if ({has_hydration_payload_error!s}) {{
toast?.error({connection_error!s}, {{id: "{hydration_toast_id}", closeButton: true, duration: Infinity}});
}} else if ({has_too_many_connection_errors!s}) {{
if (!userDismissed) {{
{toast_var!s}
}}
Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "135dd395a27f01be303b3879ece179c1",
"packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "931b222daf3755f67281b6c4d7155f85",
"packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "2d83dc211884423496c31d372d3f3734",
"packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "473666c89f74a1fcd82f12cc2cd34f43",
"packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "85ff5f20fc7a59de5544858cfc364cf1",
"packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "21d51b69ab11279e864f7aca9307462f",
"packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "86b2bea0aeec5334dbb64b3addae91df",
"packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "a43533eba05dec1ece2602bd64ca0f01",
Expand Down
62 changes: 44 additions & 18 deletions tests/integration/tests_playwright/test_hydration_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,43 +655,39 @@ def test_synced_storage_event_while_disconnected_sends_current_value(
assert page.evaluate("localStorage.getItem('hydrate-sync')") == "fresh"


@pytest.mark.parametrize("limit", ["server", "proxy"])
def test_refused_hydration_retries_with_backoff(
hydration_storage_app: AppHarness, page: Page, limit: str
hydration_storage_app: AppHarness, page: Page
):
"""A CONNECT packet dropped for its size is retried with backoff and reported.
"""A CONNECT packet dropped by a proxy limit is retried with backoff and reported.

Args:
hydration_storage_app: The running app.
page: A fresh browser page.
limit: Whether the server limit or a smaller proxy limit drops the packet.
"""
assert hydration_storage_app.frontend_url is not None
opens: list[float] = []
page.on(
"websocket",
lambda ws: opens.append(time.monotonic()) if "/_event" in ws.url else None,
)
if limit == "proxy":

def proxy(route: WebSocketRoute):
"""Close the transport on a large frame, like a proxy with its own limit.
def proxy(route: WebSocketRoute):
"""Close the transport on a large frame, like a proxy with its own limit.

Args:
route: The intercepted websocket.
"""
server = route.connect_to_server()
route.on_message(
lambda m: route.close() if len(m) > 100_000 else server.send(m)
)
server.on_message(route.send)
Args:
route: The intercepted websocket.
"""
server = route.connect_to_server()
route.on_message(
lambda m: route.close() if len(m) > 100_000 else server.send(m)
)
server.on_message(route.send)

page.route_web_socket(re.compile(r".*/_event"), proxy)
page.route_web_socket(re.compile(r".*/_event"), proxy)
page.goto(hydration_storage_app.frontend_url)
expect(page.locator("#hydrated")).to_have_text("true")

size = 1_100_000 if limit == "server" else 200_000
page.evaluate("n => localStorage.setItem('hydrate-local', 'x'.repeat(n))", size)
page.evaluate("localStorage.setItem('hydrate-local', 'x'.repeat(200_000))")
opens.clear()
page.reload()
expect(page.get_by_text("Cannot connect to server")).to_be_visible()
Expand All @@ -709,3 +705,33 @@ def proxy(route: WebSocketRoute):
page.evaluate("localStorage.removeItem('hydrate-local')")
page.reload()
expect(page.locator("#hydrated")).to_have_text("true")


def test_hydration_over_server_limit_is_reported_once(
hydration_storage_app: AppHarness, page: Page
):
"""A hydrate over the server's announced limit is reported and not sent.

Args:
hydration_storage_app: The running app.
page: A fresh browser page.
"""
assert hydration_storage_app.frontend_url is not None
opens: list[str] = []
page.on(
"websocket", lambda ws: opens.append(ws.url) if "/_event" in ws.url else None
)
page.goto(hydration_storage_app.frontend_url)
expect(page.locator("#hydrated")).to_have_text("true")

page.evaluate("localStorage.setItem('hydrate-local', 'x'.repeat(1_100_000))")
opens.clear()
page.reload()
expect(page.get_by_text("Reduce browser storage data and reload")).to_be_visible()
page.wait_for_timeout(2000)
assert len(opens) == 1, opens
expect(page.locator("#hydrated")).to_have_text("false")

page.evaluate("localStorage.removeItem('hydrate-local')")
page.reload()
expect(page.locator("#hydrated")).to_have_text("true")
Loading
Loading