Browse Source

add pulser for connection + adjust condition for connnection_modal (#2676)

* add pulser for connection + adjust condition for connnection_modal

* update style for connection pulser

* rename connectError to connectErrors

* resolve update bug of connectErrors

* fix pulse definition

* rollback pulse definition

* Define WifiOffPulse icon as its own component

Attach the pulse keyframes and imports to the icon itself so that the code gets
properly included in stateful_components.js when it is used.

* limit number of errors in memory

---------

Co-authored-by: Masen Furer <m_github@0x26.net>
Thomas Brandého 1 year ago
parent
commit
cc678e8648

+ 2 - 2
reflex/.templates/jinja/web/utils/context.js.jinja2

@@ -80,13 +80,13 @@ export function UploadFilesProvider({ children }) {
 
 export function EventLoopProvider({ children }) {
   const dispatch = useContext(DispatchContext)
-  const [addEvents, connectError] = useEventLoop(
+  const [addEvents, connectErrors] = useEventLoop(
     dispatch,
     initialEvents,
     clientStorage,
   )
   return (
-    <EventLoopContext.Provider value={[addEvents, connectError]}>
+    <EventLoopContext.Provider value={[addEvents, connectErrors]}>
       {children}
     </EventLoopContext.Provider>
   )

+ 187 - 146
reflex/.templates/web/utils/state.js

@@ -6,14 +6,19 @@ import env from "/env.json";
 import Cookies from "universal-cookie";
 import { useEffect, useReducer, useRef, useState } from "react";
 import Router, { useRouter } from "next/router";
-import { initialEvents, initialState, onLoadInternalEvent, state_name } from "utils/context.js"
+import {
+  initialEvents,
+  initialState,
+  onLoadInternalEvent,
+  state_name,
+} from "utils/context.js";
 
 // Endpoint URLs.
-const EVENTURL = env.EVENT
-const UPLOADURL = env.UPLOAD
+const EVENTURL = env.EVENT;
+const UPLOADURL = env.UPLOAD;
 
 // 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"]
+const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"];
 
 // Global variable to hold the token.
 let token;
@@ -28,7 +33,7 @@ const cookies = new Cookies();
 export const refs = {};
 
 // Flag ensures that only one event is processing on the backend concurrently.
-let event_processing = false
+let event_processing = false;
 // Array holding pending events to be processed.
 const event_queue = [];
 
@@ -64,7 +69,7 @@ export const getToken = () => {
   if (token) {
     return token;
   }
-  if (typeof window !== 'undefined') {
+  if (typeof window !== "undefined") {
     if (!window.sessionStorage.getItem(TOKEN_KEY)) {
       window.sessionStorage.setItem(TOKEN_KEY, generateUUID());
     }
@@ -81,7 +86,10 @@ export const getToken = () => {
 export const getBackendURL = (url_str) => {
   // Get backend URL object from the endpoint.
   const endpoint = new URL(url_str);
-  if ((typeof window !== 'undefined') && SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname)) {
+  if (
+    typeof window !== "undefined" &&
+    SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname)
+  ) {
     // Use the frontend domain to access the backend
     const frontend_hostname = window.location.hostname;
     endpoint.hostname = frontend_hostname;
@@ -91,11 +99,11 @@ export const getBackendURL = (url_str) => {
       } else if (endpoint.protocol === "http:") {
         endpoint.protocol = "https:";
       }
-      endpoint.port = "";  // Assume websocket is on https port via load balancer.
+      endpoint.port = ""; // Assume websocket is on https port via load balancer.
     }
   }
-  return endpoint
-}
+  return endpoint;
+};
 
 /**
  * Apply a delta to the state.
@@ -103,10 +111,9 @@ export const getBackendURL = (url_str) => {
  * @param delta The delta to apply.
  */
 export const applyDelta = (state, delta) => {
-  return { ...state, ...delta }
+  return { ...state, ...delta };
 };
 
-
 /**
  * Handle frontend event or send the event to the backend via Websocket.
  * @param event The event to send.
@@ -117,10 +124,8 @@ export const applyDelta = (state, delta) => {
 export const applyEvent = async (event, socket) => {
   // Handle special events
   if (event.name == "_redirect") {
-    if (event.payload.external)
-      window.open(event.payload.path, "_blank");
-    else
-      Router.push(event.payload.path);
+    if (event.payload.external) window.open(event.payload.path, "_blank");
+    else Router.push(event.payload.path);
     return false;
   }
 
@@ -130,20 +135,20 @@ export const applyEvent = async (event, socket) => {
   }
 
   if (event.name == "_remove_cookie") {
-    cookies.remove(event.payload.key, { ...event.payload.options })
-    queueEvents(initialEvents(), socket)
+    cookies.remove(event.payload.key, { ...event.payload.options });
+    queueEvents(initialEvents(), socket);
     return false;
   }
 
   if (event.name == "_clear_local_storage") {
     localStorage.clear();
-    queueEvents(initialEvents(), socket)
+    queueEvents(initialEvents(), socket);
     return false;
   }
 
   if (event.name == "_remove_local_storage") {
     localStorage.removeItem(event.payload.key);
-    queueEvents(initialEvents(), socket)
+    queueEvents(initialEvents(), socket);
     return false;
   }
 
@@ -154,9 +159,9 @@ export const applyEvent = async (event, socket) => {
   }
 
   if (event.name == "_download") {
-    const a = document.createElement('a');
+    const a = document.createElement("a");
     a.hidden = true;
-    a.href = event.payload.url
+    a.href = event.payload.url;
     a.download = event.payload.filename;
     a.click();
     a.remove();
@@ -188,10 +193,10 @@ export const applyEvent = async (event, socket) => {
     try {
       const eval_result = eval(event.payload.javascript_code);
       if (event.payload.callback) {
-        if (!!eval_result && typeof eval_result.then === 'function') {
-          eval(event.payload.callback)(await eval_result)
+        if (!!eval_result && typeof eval_result.then === "function") {
+          eval(event.payload.callback)(await eval_result);
         } else {
-          eval(event.payload.callback)(eval_result)
+          eval(event.payload.callback)(eval_result);
         }
       }
     } catch (e) {
@@ -201,14 +206,24 @@ export const applyEvent = async (event, socket) => {
   }
 
   // Update token and router data (if missing).
-  event.token = getToken()
-  if (event.router_data === undefined || Object.keys(event.router_data).length === 0) {
-    event.router_data = (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(Router)
+  event.token = getToken();
+  if (
+    event.router_data === undefined ||
+    Object.keys(event.router_data).length === 0
+  ) {
+    event.router_data = (({ pathname, query, asPath }) => ({
+      pathname,
+      query,
+      asPath,
+    }))(Router);
   }
 
   // Send the event to the server.
   if (socket) {
-    socket.emit("event", JSON.stringify(event, (k, v) => v === undefined ? null : v));
+    socket.emit(
+      "event",
+      JSON.stringify(event, (k, v) => (v === undefined ? null : v))
+    );
     return true;
   }
 
@@ -244,17 +259,15 @@ export const applyRestEvent = async (event, socket) => {
  * @param socket The socket object to send the event on.
  */
 export const queueEvents = async (events, socket) => {
-  event_queue.push(...events)
-  await processEvent(socket.current)
-}
+  event_queue.push(...events);
+  await processEvent(socket.current);
+};
 
 /**
  * Process an event off the event queue.
  * @param socket The socket object to send the event on.
  */
-export const processEvent = async (
-  socket
-) => {
+export const processEvent = async (socket) => {
   // Only proceed if the socket is up, otherwise we throw the event into the void
   if (!socket) {
     return;
@@ -266,12 +279,12 @@ export const processEvent = async (
   }
 
   // Set processing to true to block other events from being processed.
-  event_processing = true
+  event_processing = true;
 
   // Apply the next event in the queue.
   const event = event_queue.shift();
 
-  let eventSent = false
+  let eventSent = false;
   // Process events with handlers via REST and all others via websockets.
   if (event.handler) {
     eventSent = await applyRestEvent(event, socket);
@@ -283,27 +296,27 @@ export const processEvent = async (
     event_processing = false;
     // recursively call processEvent to drain the queue, since there is
     // no state update to trigger the useEffect event loop.
-    await processEvent(socket)
+    await processEvent(socket);
   }
-}
+};
 
 /**
  * Connect to a websocket and set the handlers.
  * @param socket The socket object to connect.
  * @param dispatch The function to queue state update
  * @param transports The transports to use.
- * @param setConnectError The function to update connection error value.
+ * @param setConnectErrors The function to update connection error value.
  * @param client_storage The client storage object from context.js
  */
 export const connect = async (
   socket,
   dispatch,
   transports,
-  setConnectError,
-  client_storage = {},
+  setConnectErrors,
+  client_storage = {}
 ) => {
   // Get backend URL object from the endpoint.
-  const endpoint = getBackendURL(EVENTURL)
+  const endpoint = getBackendURL(EVENTURL);
 
   // Create the socket.
   socket.current = io(endpoint.href, {
@@ -314,23 +327,22 @@ export const connect = async (
 
   // Once the socket is open, hydrate the page.
   socket.current.on("connect", () => {
-    setConnectError(null)
+    setConnectErrors([]);
   });
 
-  socket.current.on('connect_error', (error) => {
-    setConnectError(error)
+  socket.current.on("connect_error", (error) => {
+    setConnectErrors((connectErrors) => [connectErrors.slice(-9), error]);
   });
-
   // On each received message, queue the updates and events.
-  socket.current.on("event", message => {
-    const update = JSON5.parse(message)
+  socket.current.on("event", (message) => {
+    const update = JSON5.parse(message);
     for (const substate in update.delta) {
-      dispatch[substate](update.delta[substate])
+      dispatch[substate](update.delta[substate]);
     }
-    applyClientStorageDelta(client_storage, update.delta)
-    event_processing = !update.final
+    applyClientStorageDelta(client_storage, update.delta);
+    event_processing = !update.final;
     if (update.events) {
-      queueEvents(update.events, socket)
+      queueEvents(update.events, socket);
     }
   });
 };
@@ -346,38 +358,44 @@ export const connect = async (
  *
  * @returns The response from posting to the UPLOADURL endpoint.
  */
-export const uploadFiles = async (handler, files, upload_id, on_upload_progress, socket) => {
+export const uploadFiles = async (
+  handler,
+  files,
+  upload_id,
+  on_upload_progress,
+  socket
+) => {
   // return if there's no file to upload
   if (files === undefined || files.length === 0) {
     return false;
   }
 
   if (upload_controllers[upload_id]) {
-    console.log("Upload already in progress for ", upload_id)
+    console.log("Upload already in progress for ", upload_id);
     return false;
   }
 
   let resp_idx = 0;
   const eventHandler = (progressEvent) => {
     // handle any delta / event streamed from the upload event handler
-    const chunks = progressEvent.event.target.responseText.trim().split("\n")
+    const chunks = progressEvent.event.target.responseText.trim().split("\n");
     chunks.slice(resp_idx).map((chunk) => {
       try {
         socket._callbacks.$event.map((f) => {
-          f(chunk)
-        })
-        resp_idx += 1
+          f(chunk);
+        });
+        resp_idx += 1;
       } catch (e) {
         if (progressEvent.progress === 1) {
           // Chunk may be incomplete, so only report errors when full response is available.
-          console.log("Error parsing chunk", chunk, e)
+          console.log("Error parsing chunk", chunk, e);
         }
-        return
+        return;
       }
-    })
-  }
+    });
+  };
 
-  const controller = new AbortController()
+  const controller = new AbortController();
   const config = {
     headers: {
       "Reflex-Client-Token": getToken(),
@@ -385,26 +403,22 @@ export const uploadFiles = async (handler, files, upload_id, on_upload_progress,
     },
     signal: controller.signal,
     onDownloadProgress: eventHandler,
-  }
+  };
   if (on_upload_progress) {
-    config["onUploadProgress"] = on_upload_progress
+    config["onUploadProgress"] = on_upload_progress;
   }
   const formdata = new FormData();
 
   // Add the token and handler to the file name.
   files.forEach((file) => {
-    formdata.append(
-      "files",
-      file,
-      file.path || file.name
-    );
-  })
+    formdata.append("files", file, file.path || file.name);
+  });
 
   // Send the file to the server.
-  upload_controllers[upload_id] = controller
+  upload_controllers[upload_id] = controller;
 
   try {
-    return await axios.post(getBackendURL(UPLOADURL), formdata, config)
+    return await axios.post(getBackendURL(UPLOADURL), formdata, config);
   } catch (error) {
     if (error.response) {
       // The request was made and the server responded with a status code
@@ -421,7 +435,7 @@ export const uploadFiles = async (handler, files, upload_id, on_upload_progress,
     }
     return false;
   } finally {
-    delete upload_controllers[upload_id]
+    delete upload_controllers[upload_id];
   }
 };
 
@@ -443,30 +457,32 @@ export const Event = (name, payload = {}, handler = null) => {
  * @returns payload dict of client storage values
  */
 export const hydrateClientStorage = (client_storage) => {
-  const client_storage_values = {}
+  const client_storage_values = {};
   if (client_storage.cookies) {
     for (const state_key in client_storage.cookies) {
-      const cookie_options = client_storage.cookies[state_key]
-      const cookie_name = cookie_options.name || state_key
-      const cookie_value = cookies.get(cookie_name)
+      const cookie_options = client_storage.cookies[state_key];
+      const cookie_name = cookie_options.name || state_key;
+      const cookie_value = cookies.get(cookie_name);
       if (cookie_value !== undefined) {
-        client_storage_values[state_key] = cookies.get(cookie_name)
+        client_storage_values[state_key] = cookies.get(cookie_name);
       }
     }
   }
-  if (client_storage.local_storage && (typeof window !== 'undefined')) {
+  if (client_storage.local_storage && typeof window !== "undefined") {
     for (const state_key in client_storage.local_storage) {
-      const options = client_storage.local_storage[state_key]
-      const local_storage_value = localStorage.getItem(options.name || state_key)
+      const options = client_storage.local_storage[state_key];
+      const local_storage_value = localStorage.getItem(
+        options.name || state_key
+      );
       if (local_storage_value !== null) {
-        client_storage_values[state_key] = local_storage_value
+        client_storage_values[state_key] = local_storage_value;
       }
     }
   }
   if (client_storage.cookies || client_storage.local_storage) {
-    return client_storage_values
+    return client_storage_values;
   }
-  return {}
+  return {};
 };
 
 /**
@@ -476,9 +492,11 @@ export const hydrateClientStorage = (client_storage) => {
  */
 const applyClientStorageDelta = (client_storage, delta) => {
   // find the main state and check for is_hydrated
-  const unqualified_states = Object.keys(delta).filter((key) => key.split(".").length === 1);
+  const unqualified_states = Object.keys(delta).filter(
+    (key) => key.split(".").length === 1
+  );
   if (unqualified_states.length === 1) {
-    const main_state = delta[unqualified_states[0]]
+    const main_state = delta[unqualified_states[0]];
     if (main_state.is_hydrated !== undefined && !main_state.is_hydrated) {
       // skip if the state is not hydrated yet, since all client storage
       // values are sent in the hydrate event
@@ -488,19 +506,23 @@ const applyClientStorageDelta = (client_storage, delta) => {
   // Save known client storage values to cookies and localStorage.
   for (const substate in delta) {
     for (const key in delta[substate]) {
-      const state_key = `${substate}.${key}`
+      const state_key = `${substate}.${key}`;
       if (client_storage.cookies && state_key in client_storage.cookies) {
-        const cookie_options = { ...client_storage.cookies[state_key] }
-        const cookie_name = cookie_options.name || state_key
-        delete cookie_options.name  // name is not a valid cookie option
+        const cookie_options = { ...client_storage.cookies[state_key] };
+        const cookie_name = cookie_options.name || state_key;
+        delete cookie_options.name; // name is not a valid cookie option
         cookies.set(cookie_name, delta[substate][key], cookie_options);
-      } else if (client_storage.local_storage && state_key in client_storage.local_storage && (typeof window !== 'undefined')) {
-        const options = client_storage.local_storage[state_key]
+      } else if (
+        client_storage.local_storage &&
+        state_key in client_storage.local_storage &&
+        typeof window !== "undefined"
+      ) {
+        const options = client_storage.local_storage[state_key];
         localStorage.setItem(options.name || state_key, delta[substate][key]);
       }
     }
   }
-}
+};
 
 /**
  * Establish websocket event loop for a NextJS page.
@@ -508,18 +530,18 @@ const applyClientStorageDelta = (client_storage, delta) => {
  * @param initial_events The initial app events.
  * @param client_storage The client storage object from context.js
  *
- * @returns [addEvents, connectError] -
+ * @returns [addEvents, connectErrors] -
  *   addEvents is used to queue an event, and
- *   connectError is a reactive js error from the websocket connection (or null if connected).
+ *   connectErrors is an array of reactive js error from the websocket connection (or null if connected).
  */
 export const useEventLoop = (
   dispatch,
   initial_events = () => [],
-  client_storage = {},
+  client_storage = {}
 ) => {
-  const socket = useRef(null)
-  const router = useRouter()
-  const [connectError, setConnectError] = useState(null)
+  const socket = useRef(null);
+  const router = useRouter();
+  const [connectErrors, setConnectErrors] = useState([]);
 
   // Function to add new events to the event queue.
   const addEvents = (events, _e, event_actions) => {
@@ -529,22 +551,26 @@ export const useEventLoop = (
     if (event_actions?.stopPropagation && _e?.stopPropagation) {
       _e.stopPropagation();
     }
-    queueEvents(events, socket)
-  }
+    queueEvents(events, socket);
+  };
 
-  const sentHydrate = useRef(false);  // Avoid double-hydrate due to React strict-mode
+  const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
   useEffect(() => {
     if (router.isReady && !sentHydrate.current) {
-    const events = initial_events()
-      addEvents(events.map((e) => (
-        {
+      const events = initial_events();
+      addEvents(
+        events.map((e) => ({
           ...e,
-          router_data: (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(router)
-        }
-      )))
-      sentHydrate.current = true
+          router_data: (({ pathname, query, asPath }) => ({
+            pathname,
+            query,
+            asPath,
+          }))(router),
+        }))
+      );
+      sentHydrate.current = true;
     }
-  }, [router.isReady])
+  }, [router.isReady]);
 
   // Main event loop.
   useEffect(() => {
@@ -556,17 +582,22 @@ export const useEventLoop = (
     if (Object.keys(initialState).length > 1) {
       // Initialize the websocket connection.
       if (!socket.current) {
-        connect(socket, dispatch, ['websocket', 'polling'], setConnectError, client_storage)
+        connect(
+          socket,
+          dispatch,
+          ["websocket", "polling"],
+          setConnectErrors,
+          client_storage
+        );
       }
       (async () => {
         // Process all outstanding events.
         while (event_queue.length > 0 && !event_processing) {
-          await processEvent(socket.current)
+          await processEvent(socket.current);
         }
-      })()
+      })();
     }
-  })
-
+  });
 
   // localStorage event handling
   useEffect(() => {
@@ -585,9 +616,12 @@ export const useEventLoop = (
     // e is StorageEvent
     const handleStorage = (e) => {
       if (storage_to_state_map[e.key]) {
-        const vars = {}
-        vars[storage_to_state_map[e.key]] = e.newValue
-        const event = Event(`${state_name}.update_vars_internal_state.update_vars_internal`, {vars: vars})
+        const vars = {};
+        vars[storage_to_state_map[e.key]] = e.newValue;
+        const event = Event(
+          `${state_name}.update_vars_internal_state.update_vars_internal`,
+          { vars: vars }
+        );
         addEvents([event], e);
       }
     };
@@ -596,18 +630,17 @@ export const useEventLoop = (
     return () => window.removeEventListener("storage", handleStorage);
   });
 
-
   // Route after the initial page hydration.
   useEffect(() => {
-    const change_complete = () => addEvents(onLoadInternalEvent())
-    router.events.on('routeChangeComplete', change_complete)
+    const change_complete = () => addEvents(onLoadInternalEvent());
+    router.events.on("routeChangeComplete", change_complete);
     return () => {
-      router.events.off('routeChangeComplete', change_complete)
-    }
-  }, [router])
+      router.events.off("routeChangeComplete", change_complete);
+    };
+  }, [router]);
 
-  return [addEvents, connectError]
-}
+  return [addEvents, connectErrors];
+};
 
 /***
  * Check if a value is truthy in python.
@@ -628,21 +661,25 @@ export const getRefValue = (ref) => {
     return;
   }
   if (ref.current.type == "checkbox") {
-    return ref.current.checked;  // chakra
-  } else if (ref.current.className?.includes("rt-CheckboxButton") || ref.current.className?.includes("rt-SwitchButton")) {
-    return ref.current.ariaChecked == "true";  // radix
+    return ref.current.checked; // chakra
+  } else if (
+    ref.current.className?.includes("rt-CheckboxButton") ||
+    ref.current.className?.includes("rt-SwitchButton")
+  ) {
+    return ref.current.ariaChecked == "true"; // radix
   } else if (ref.current.className?.includes("rt-SliderRoot")) {
     // find the actual slider
     return ref.current.querySelector(".rt-SliderThumb")?.ariaValueNow;
   } else {
     //querySelector(":checked") is needed to get value from radio_group
-    return ref.current.value || (
-      ref.current.querySelector
-      && ref.current.querySelector(':checked')
-      && ref.current.querySelector(':checked')?.value
+    return (
+      ref.current.value ||
+      (ref.current.querySelector &&
+        ref.current.querySelector(":checked") &&
+        ref.current.querySelector(":checked")?.value)
     );
   }
-}
+};
 
 /**
  * Get the values from a ref array.
@@ -654,21 +691,25 @@ export const getRefValues = (refs) => {
     return;
   }
   // getAttribute is used by RangeSlider because it doesn't assign value
-  return refs.map((ref) => ref.current ? ref.current.value || ref.current.getAttribute("aria-valuenow") : null);
-}
+  return refs.map((ref) =>
+    ref.current
+      ? ref.current.value || ref.current.getAttribute("aria-valuenow")
+      : null
+  );
+};
 
 /**
-* Spread two arrays or two objects.
-* @param first The first array or object.
-* @param second The second array or object.
-* @returns The final merged array or object.
-*/
+ * Spread two arrays or two objects.
+ * @param first The first array or object.
+ * @param second The second array or object.
+ * @returns The final merged array or object.
+ */
 export const spreadArraysOrObjects = (first, second) => {
   if (Array.isArray(first) && Array.isArray(second)) {
     return [...first, ...second];
-  } else if (typeof first === 'object' && typeof second === 'object') {
+  } else if (typeof first === "object" && typeof second === "object") {
     return { ...first, ...second };
   } else {
-    throw new Error('Both parameters must be either arrays or objects.');
+    throw new Error("Both parameters must be either arrays or objects.");
   }
-}
+};

+ 14 - 13
reflex/app.py

@@ -1,4 +1,5 @@
 """The main Reflex app."""
+
 from __future__ import annotations
 
 import asyncio
@@ -36,7 +37,7 @@ from reflex.admin import AdminDash
 from reflex.base import Base
 from reflex.compiler import compiler
 from reflex.compiler import utils as compiler_utils
-from reflex.components import connection_modal
+from reflex.components import connection_modal, connection_pulser
 from reflex.components.base.app_wrap import AppWrap
 from reflex.components.base.fragment import Fragment
 from reflex.components.component import (
@@ -87,7 +88,7 @@ def default_overlay_component() -> Component:
     Returns:
         The default overlay_component, which is a connection_modal.
     """
-    return connection_modal()
+    return Fragment.create(connection_pulser(), connection_modal())
 
 
 class App(Base):
@@ -198,9 +199,11 @@ class App(Base):
             # Set up the Socket.IO AsyncServer.
             self.sio = AsyncServer(
                 async_mode="asgi",
-                cors_allowed_origins="*"
-                if config.cors_allowed_origins == ["*"]
-                else config.cors_allowed_origins,
+                cors_allowed_origins=(
+                    "*"
+                    if config.cors_allowed_origins == ["*"]
+                    else config.cors_allowed_origins
+                ),
                 cors_credentials=True,
                 max_http_buffer_size=constants.POLLING_MAX_HTTP_BUFFER_SIZE,
                 ping_interval=constants.Ping.INTERVAL,
@@ -387,10 +390,9 @@ class App(Base):
         title: str = constants.DefaultPage.TITLE,
         description: str = constants.DefaultPage.DESCRIPTION,
         image: str = constants.DefaultPage.IMAGE,
-        on_load: EventHandler
-        | EventSpec
-        | list[EventHandler | EventSpec]
-        | None = None,
+        on_load: (
+            EventHandler | EventSpec | list[EventHandler | EventSpec] | None
+        ) = None,
         meta: list[dict[str, str]] = constants.DefaultPage.META_LIST,
         script_tags: list[Component] | None = None,
     ):
@@ -520,10 +522,9 @@ class App(Base):
         title: str = constants.Page404.TITLE,
         image: str = constants.Page404.IMAGE,
         description: str = constants.Page404.DESCRIPTION,
-        on_load: EventHandler
-        | EventSpec
-        | list[EventHandler | EventSpec]
-        | None = None,
+        on_load: (
+            EventHandler | EventSpec | list[EventHandler | EventSpec] | None
+        ) = None,
         meta: list[dict[str, str]] = constants.DefaultPage.META_LIST,
     ):
         """Define a custom 404 page for any url having no match.

+ 2 - 1
reflex/components/core/__init__.py

@@ -1,7 +1,7 @@
 """Core Reflex components."""
 
 from . import layout as layout
-from .banner import ConnectionBanner, ConnectionModal
+from .banner import ConnectionBanner, ConnectionModal, ConnectionPulser
 from .colors import color
 from .cond import Cond, color_mode_cond, cond
 from .debounce import DebounceInput
@@ -26,6 +26,7 @@ from .upload import (
 
 connection_banner = ConnectionBanner.create
 connection_modal = ConnectionModal.create
+connection_pulser = ConnectionPulser.create
 debounce_input = DebounceInput.create
 foreach = Foreach.create
 html = Html.create

+ 99 - 11
reflex/components/core/banner.py

@@ -7,14 +7,17 @@ from typing import Optional
 from reflex.components.base.bare import Bare
 from reflex.components.component import Component
 from reflex.components.core.cond import cond
+from reflex.components.el.elements.typography import Div
+from reflex.components.lucide.icon import Icon
 from reflex.components.radix.themes.components.dialog import (
     DialogContent,
     DialogRoot,
     DialogTitle,
 )
-from reflex.components.radix.themes.layout import Box
+from reflex.components.radix.themes.layout import Flex
 from reflex.components.radix.themes.typography.text import Text
 from reflex.constants import Dirs, Hooks, Imports
+from reflex.state import State
 from reflex.utils import imports
 from reflex.vars import Var, VarData
 
@@ -24,12 +27,24 @@ connect_error_var_data: VarData = VarData(  # type: ignore
 )
 
 connection_error: Var = Var.create_safe(
-    value="(connectError !== null) ? connectError.message : ''",
+    value="(connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : ''",
     _var_is_local=False,
     _var_is_string=False,
 )._replace(merge_var_data=connect_error_var_data)
-has_connection_error: Var = Var.create_safe(
-    value="connectError !== null",
+
+connection_errors_count: Var = Var.create_safe(
+    value="connectErrors.length",
+    _var_is_string=False,
+    _var_is_local=False,
+)._replace(merge_var_data=connect_error_var_data)
+
+has_connection_errors: Var = Var.create_safe(
+    value="connectErrors.length > 0",
+    _var_is_string=False,
+)._replace(_var_type=bool, merge_var_data=connect_error_var_data)
+
+has_too_many_connection_errors: Var = Var.create_safe(
+    value="connectErrors.length >= 2",
     _var_is_string=False,
 )._replace(_var_type=bool, merge_var_data=connect_error_var_data)
 
@@ -81,16 +96,20 @@ class ConnectionBanner(Component):
             The connection banner component.
         """
         if not comp:
-            comp = Box.create(
+            comp = Flex.create(
                 Text.create(
                     *default_connection_error(),
-                    bg="red",
-                    color="white",
+                    color="black",
+                    size="4",
                 ),
-                textAlign="center",
+                justify="center",
+                background_color="crimson",
+                width="100vw",
+                padding="5px",
+                position="fixed",
             )
 
-        return cond(has_connection_error, comp)
+        return cond(has_connection_errors, comp)
 
 
 class ConnectionModal(Component):
@@ -109,12 +128,81 @@ class ConnectionModal(Component):
         if not comp:
             comp = Text.create(*default_connection_error())
         return cond(
-            has_connection_error,
+            has_too_many_connection_errors,
             DialogRoot.create(
                 DialogContent.create(
                     DialogTitle.create("Connection Error"),
                     comp,
                 ),
-                open=has_connection_error,
+                open=has_too_many_connection_errors,
+                z_index=9999,
+            ),
+        )
+
+
+class WifiOffPulse(Icon):
+    """A wifi_off icon with an animated opacity pulse."""
+
+    @classmethod
+    def create(cls, **props) -> Component:
+        """Create a wifi_off icon with an animated opacity pulse.
+
+        Args:
+            **props: The properties of the component.
+
+        Returns:
+            The icon component with default props applied.
+        """
+        return super().create(
+            "wifi_off",
+            color=props.pop("color", "crimson"),
+            size=props.pop("size", 32),
+            z_index=props.pop("z_index", 9999),
+            position=props.pop("position", "fixed"),
+            bottom=props.pop("botton", "30px"),
+            right=props.pop("right", "30px"),
+            animation=Var.create(f"${{pulse}} 1s infinite", _var_is_string=True),
+            **props,
+        )
+
+    def _get_imports(self) -> imports.ImportDict:
+        return imports.merge_imports(
+            super()._get_imports(),
+            {"@emotion/react": [imports.ImportVar(tag="keyframes")]},
+        )
+
+    def _get_custom_code(self) -> str | None:
+        return """
+const pulse = keyframes`
+    0% {
+        opacity: 0;
+    }
+    100% {
+        opacity: 1;
+    }
+`
+"""
+
+
+class ConnectionPulser(Div):
+    """A connection pulser component."""
+
+    @classmethod
+    def create(cls, **props) -> Component:
+        """Create a connection pulser component.
+
+        Args:
+            **props: The properties of the component.
+
+        Returns:
+            The connection pulser component.
+        """
+        return super().create(
+            cond(
+                ~State.is_hydrated | has_connection_errors,  # type: ignore
+                WifiOffPulse.create(**props),
             ),
+            position="fixed",
+            width="100vw",
+            height="0",
         )

+ 215 - 2
reflex/components/core/banner.pyi

@@ -11,20 +11,25 @@ from typing import Optional
 from reflex.components.base.bare import Bare
 from reflex.components.component import Component
 from reflex.components.core.cond import cond
+from reflex.components.el.elements.typography import Div
+from reflex.components.lucide.icon import Icon
 from reflex.components.radix.themes.components.dialog import (
     DialogContent,
     DialogRoot,
     DialogTitle,
 )
-from reflex.components.radix.themes.layout import Box
+from reflex.components.radix.themes.layout import Flex
 from reflex.components.radix.themes.typography.text import Text
 from reflex.constants import Dirs, Hooks, Imports
+from reflex.state import State
 from reflex.utils import imports
 from reflex.vars import Var, VarData
 
 connect_error_var_data: VarData
 connection_error: Var
-has_connection_error: Var
+connection_errors_count: Var
+has_connection_errors: Var
+has_too_many_connection_errors: Var
 
 class WebsocketTargetURL(Bare):
     @overload
@@ -232,3 +237,211 @@ class ConnectionModal(Component):
             The connection banner component.
         """
         ...
+
+class WifiOffPulse(Icon):
+    @overload
+    @classmethod
+    def create(  # type: ignore
+        cls,
+        *children,
+        size: Optional[Union[Var[int], int]] = None,
+        style: Optional[Style] = None,
+        key: Optional[Any] = None,
+        id: Optional[Any] = None,
+        class_name: Optional[Any] = None,
+        autofocus: Optional[bool] = None,
+        custom_attrs: Optional[Dict[str, Union[Var, str]]] = None,
+        on_blur: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_click: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_context_menu: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_double_click: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_focus: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mount: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_down: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_enter: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_leave: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_move: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_out: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_over: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_up: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_scroll: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_unmount: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        **props
+    ) -> "WifiOffPulse":
+        """Create a wifi_off icon with an animated opacity pulse.
+
+        Args:
+            size: The size of the icon in pixels.
+            style: The style of the component.
+            key: A unique key for the component.
+            id: The id for the component.
+            class_name: The class name for the component.
+            autofocus: Whether the component should take the focus once the page is loaded
+            custom_attrs: custom attribute
+            **props: The properties of the component.
+
+        Returns:
+            The icon component with default props applied.
+        """
+        ...
+
+class ConnectionPulser(Div):
+    @overload
+    @classmethod
+    def create(  # type: ignore
+        cls,
+        *children,
+        access_key: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        auto_capitalize: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        content_editable: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        context_menu: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        dir: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None,
+        draggable: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        enter_key_hint: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        hidden: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        input_mode: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        item_prop: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        lang: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None,
+        role: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None,
+        slot: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None,
+        spell_check: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        tab_index: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        title: Optional[
+            Union[Var[Union[str, int, bool]], Union[str, int, bool]]
+        ] = None,
+        style: Optional[Style] = None,
+        key: Optional[Any] = None,
+        id: Optional[Any] = None,
+        class_name: Optional[Any] = None,
+        autofocus: Optional[bool] = None,
+        custom_attrs: Optional[Dict[str, Union[Var, str]]] = None,
+        on_blur: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_click: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_context_menu: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_double_click: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_focus: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mount: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_down: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_enter: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_leave: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_move: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_out: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_over: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_mouse_up: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_scroll: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        on_unmount: Optional[
+            Union[EventHandler, EventSpec, list, function, BaseVar]
+        ] = None,
+        **props
+    ) -> "ConnectionPulser":
+        """Create a connection pulser component.
+
+        Args:
+            access_key:  Provides a hint for generating a keyboard shortcut for the current element.
+            auto_capitalize: Controls whether and how text input is automatically capitalized as it is entered/edited by the user.
+            content_editable: Indicates whether the element's content is editable.
+            context_menu: Defines the ID of a <menu> element which will serve as the element's context menu.
+            dir: Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left)
+            draggable: Defines whether the element can be dragged.
+            enter_key_hint: Hints what media types the media element is able to play.
+            hidden: Defines whether the element is hidden.
+            input_mode: Defines the type of the element.
+            item_prop: Defines the name of the element for metadata purposes.
+            lang: Defines the language used in the element.
+            role: Defines the role of the element.
+            slot: Assigns a slot in a shadow DOM shadow tree to an element.
+            spell_check: Defines whether the element may be checked for spelling errors.
+            tab_index: Defines the position of the current element in the tabbing order.
+            title: Defines a tooltip for the element.
+            style: The style of the component.
+            key: A unique key for the component.
+            id: The id for the component.
+            class_name: The class name for the component.
+            autofocus: Whether the component should take the focus once the page is loaded
+            custom_attrs: custom attribute
+            **props: The properties of the component.
+
+        Returns:
+            The connection pulser component.
+        """
+        ...

+ 2 - 1
reflex/constants/compiler.py

@@ -1,4 +1,5 @@
 """Compiler variables."""
+
 import enum
 from enum import Enum
 from types import SimpleNamespace
@@ -55,7 +56,7 @@ class CompileVars(SimpleNamespace):
     # The name of the function to add events to the queue.
     ADD_EVENTS = "addEvents"
     # The name of the var storing any connection error.
-    CONNECT_ERROR = "connectError"
+    CONNECT_ERROR = "connectErrors"
     # The name of the function for converting a dict to an event.
     TO_EVENT = "Event"
     # The name of the internal on_load event.