state.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. // State management for Reflex web apps.
  2. import axios from "axios";
  3. import io from "socket.io-client";
  4. import JSON5 from "json5";
  5. import env from "$/env.json";
  6. import reflexEnvironment from "$/reflex.json";
  7. import Cookies from "universal-cookie";
  8. import { useEffect, useRef, useState } from "react";
  9. import Router, { useRouter } from "next/router";
  10. import {
  11. initialEvents,
  12. initialState,
  13. onLoadInternalEvent,
  14. state_name,
  15. exception_state_name,
  16. } from "$/utils/context.js";
  17. import debounce from "$/utils/helpers/debounce";
  18. import throttle from "$/utils/helpers/throttle";
  19. // Endpoint URLs.
  20. const EVENTURL = env.EVENT;
  21. const UPLOADURL = env.UPLOAD;
  22. // These hostnames indicate that the backend and frontend are reachable via the same domain.
  23. const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"];
  24. // Global variable to hold the token.
  25. let token;
  26. // Key for the token in the session storage.
  27. const TOKEN_KEY = "token";
  28. // create cookie instance
  29. const cookies = new Cookies();
  30. // Dictionary holding component references.
  31. export const refs = {};
  32. // Flag ensures that only one event is processing on the backend concurrently.
  33. let event_processing = false;
  34. // Array holding pending events to be processed.
  35. const event_queue = [];
  36. /**
  37. * Generate a UUID (Used for session tokens).
  38. * Taken from: https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
  39. * @returns A UUID.
  40. */
  41. export const generateUUID = () => {
  42. let d = new Date().getTime(),
  43. d2 = (performance && performance.now && performance.now() * 1000) || 0;
  44. return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
  45. let r = Math.random() * 16;
  46. if (d > 0) {
  47. r = (d + r) % 16 | 0;
  48. d = Math.floor(d / 16);
  49. } else {
  50. r = (d2 + r) % 16 | 0;
  51. d2 = Math.floor(d2 / 16);
  52. }
  53. return (c == "x" ? r : (r & 0x7) | 0x8).toString(16);
  54. });
  55. };
  56. /**
  57. * Get the token for the current session.
  58. * @returns The token.
  59. */
  60. export const getToken = () => {
  61. if (token) {
  62. return token;
  63. }
  64. if (typeof window !== "undefined") {
  65. if (!window.sessionStorage.getItem(TOKEN_KEY)) {
  66. window.sessionStorage.setItem(TOKEN_KEY, generateUUID());
  67. }
  68. token = window.sessionStorage.getItem(TOKEN_KEY);
  69. }
  70. return token;
  71. };
  72. /**
  73. * Get the URL for the backend server
  74. * @param url_str The URL string to parse.
  75. * @returns The given URL modified to point to the actual backend server.
  76. */
  77. export const getBackendURL = (url_str) => {
  78. // Get backend URL object from the endpoint.
  79. const endpoint = new URL(url_str);
  80. if (
  81. typeof window !== "undefined" &&
  82. SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname)
  83. ) {
  84. // Use the frontend domain to access the backend
  85. const frontend_hostname = window.location.hostname;
  86. endpoint.hostname = frontend_hostname;
  87. if (window.location.protocol === "https:") {
  88. if (endpoint.protocol === "ws:") {
  89. endpoint.protocol = "wss:";
  90. } else if (endpoint.protocol === "http:") {
  91. endpoint.protocol = "https:";
  92. }
  93. endpoint.port = ""; // Assume websocket is on https port via load balancer.
  94. }
  95. }
  96. return endpoint;
  97. };
  98. /**
  99. * Check if the backend is disabled.
  100. *
  101. * @returns True if the backend is disabled, false otherwise.
  102. */
  103. export const isBackendDisabled = () => {
  104. const cookie = document.cookie
  105. .split("; ")
  106. .find((row) => row.startsWith("backend-enabled="));
  107. return cookie !== undefined && cookie.split("=")[1] == "false";
  108. };
  109. /**
  110. * Determine if any event in the event queue is stateful.
  111. *
  112. * @returns True if there's any event that requires state and False if none of them do.
  113. */
  114. export const isStateful = () => {
  115. if (event_queue.length === 0) {
  116. return false;
  117. }
  118. return event_queue.some((event) => event.name.startsWith("reflex___state"));
  119. };
  120. /**
  121. * Apply a delta to the state.
  122. * @param state The state to apply the delta to.
  123. * @param delta The delta to apply.
  124. */
  125. export const applyDelta = (state, delta) => {
  126. return { ...state, ...delta };
  127. };
  128. /**
  129. * Evaluate a dynamic component.
  130. * @param component The component to evaluate.
  131. * @returns The evaluated component.
  132. */
  133. export const evalReactComponent = async (component) => {
  134. if (!window.React && window.__reflex) {
  135. window.React = window.__reflex.react;
  136. }
  137. const encodedJs = encodeURIComponent(component);
  138. const dataUri = "data:text/javascript;charset=utf-8," + encodedJs;
  139. const module = await eval(`import(dataUri)`);
  140. return module.default;
  141. };
  142. /**
  143. * Only Queue and process events when websocket connection exists.
  144. * @param event The event to queue.
  145. * @param socket The socket object to send the event on.
  146. *
  147. * @returns Adds event to queue and processes it if websocket exits, does nothing otherwise.
  148. */
  149. export const queueEventIfSocketExists = async (events, socket) => {
  150. if (!socket) {
  151. return;
  152. }
  153. await queueEvents(events, socket);
  154. };
  155. /**
  156. * Handle frontend event or send the event to the backend via Websocket.
  157. * @param event The event to send.
  158. * @param socket The socket object to send the event on.
  159. *
  160. * @returns True if the event was sent, false if it was handled locally.
  161. */
  162. export const applyEvent = async (event, socket) => {
  163. // Handle special events
  164. if (event.name == "_redirect") {
  165. if ((event.payload.path ?? undefined) === undefined) {
  166. return false;
  167. }
  168. if (event.payload.external) {
  169. window.open(event.payload.path, "_blank", "noopener");
  170. } else if (event.payload.replace) {
  171. Router.replace(event.payload.path);
  172. } else {
  173. Router.push(event.payload.path);
  174. }
  175. return false;
  176. }
  177. if (event.name == "_remove_cookie") {
  178. cookies.remove(event.payload.key, { ...event.payload.options });
  179. queueEventIfSocketExists(initialEvents(), socket);
  180. return false;
  181. }
  182. if (event.name == "_clear_local_storage") {
  183. localStorage.clear();
  184. queueEventIfSocketExists(initialEvents(), socket);
  185. return false;
  186. }
  187. if (event.name == "_remove_local_storage") {
  188. localStorage.removeItem(event.payload.key);
  189. queueEventIfSocketExists(initialEvents(), socket);
  190. return false;
  191. }
  192. if (event.name == "_clear_session_storage") {
  193. sessionStorage.clear();
  194. queueEvents(initialEvents(), socket);
  195. return false;
  196. }
  197. if (event.name == "_remove_session_storage") {
  198. sessionStorage.removeItem(event.payload.key);
  199. queueEvents(initialEvents(), socket);
  200. return false;
  201. }
  202. if (event.name == "_download") {
  203. const a = document.createElement("a");
  204. a.hidden = true;
  205. a.href = event.payload.url;
  206. // Special case when linking to uploaded files
  207. if (a.href.includes("getBackendURL(env.UPLOAD)")) {
  208. a.href = eval?.(
  209. event.payload.url.replace(
  210. "getBackendURL(env.UPLOAD)",
  211. `"${getBackendURL(env.UPLOAD)}"`,
  212. ),
  213. );
  214. }
  215. a.download = event.payload.filename;
  216. a.click();
  217. a.remove();
  218. return false;
  219. }
  220. if (event.name == "_set_focus") {
  221. const ref =
  222. event.payload.ref in refs ? refs[event.payload.ref] : event.payload.ref;
  223. const current = ref?.current;
  224. if (current === undefined || current?.focus === undefined) {
  225. console.error(
  226. `No element found for ref ${event.payload.ref} in _set_focus`,
  227. );
  228. } else {
  229. current.focus();
  230. }
  231. return false;
  232. }
  233. if (event.name == "_set_value") {
  234. const ref =
  235. event.payload.ref in refs ? refs[event.payload.ref] : event.payload.ref;
  236. if (ref.current) {
  237. ref.current.value = event.payload.value;
  238. }
  239. return false;
  240. }
  241. if (
  242. event.name == "_call_function" &&
  243. typeof event.payload.function !== "string"
  244. ) {
  245. try {
  246. const eval_result = event.payload.function();
  247. if (event.payload.callback) {
  248. const final_result =
  249. !!eval_result && typeof eval_result.then === "function"
  250. ? await eval_result
  251. : eval_result;
  252. const callback =
  253. typeof event.payload.callback === "string"
  254. ? eval(event.payload.callback)
  255. : event.payload.callback;
  256. callback(final_result);
  257. }
  258. } catch (e) {
  259. console.log("_call_function", e);
  260. if (window && window?.onerror) {
  261. window.onerror(e.message, null, null, null, e);
  262. }
  263. }
  264. return false;
  265. }
  266. if (event.name == "_call_script" || event.name == "_call_function") {
  267. try {
  268. const eval_result =
  269. event.name == "_call_script"
  270. ? eval(event.payload.javascript_code)
  271. : eval(event.payload.function)();
  272. if (event.payload.callback) {
  273. const final_result =
  274. !!eval_result && typeof eval_result.then === "function"
  275. ? await eval_result
  276. : eval_result;
  277. const callback =
  278. typeof event.payload.callback === "string"
  279. ? eval(event.payload.callback)
  280. : event.payload.callback;
  281. callback(final_result);
  282. }
  283. } catch (e) {
  284. console.log("_call_script", e);
  285. if (window && window?.onerror) {
  286. window.onerror(e.message, null, null, null, e);
  287. }
  288. }
  289. return false;
  290. }
  291. // Update token and router data (if missing).
  292. event.token = getToken();
  293. if (
  294. event.router_data === undefined ||
  295. Object.keys(event.router_data).length === 0
  296. ) {
  297. event.router_data = (({ pathname, query, asPath }) => ({
  298. pathname,
  299. query,
  300. asPath,
  301. }))(Router);
  302. }
  303. // Send the event to the server.
  304. if (socket) {
  305. socket.emit("event", event);
  306. return true;
  307. }
  308. return false;
  309. };
  310. /**
  311. * Send an event to the server via REST.
  312. * @param event The current event.
  313. * @param socket The socket object to send the response event(s) on.
  314. *
  315. * @returns Whether the event was sent.
  316. */
  317. export const applyRestEvent = async (event, socket) => {
  318. let eventSent = false;
  319. if (event.handler === "uploadFiles") {
  320. if (event.payload.files === undefined || event.payload.files.length === 0) {
  321. // Submit the event over the websocket to trigger the event handler.
  322. return await applyEvent(Event(event.name), socket);
  323. }
  324. // Start upload, but do not wait for it, which would block other events.
  325. uploadFiles(
  326. event.name,
  327. event.payload.files,
  328. event.payload.upload_id,
  329. event.payload.on_upload_progress,
  330. socket,
  331. );
  332. return false;
  333. }
  334. return eventSent;
  335. };
  336. /**
  337. * Queue events to be processed and trigger processing of queue.
  338. * @param events Array of events to queue.
  339. * @param socket The socket object to send the event on.
  340. * @param prepend Whether to place the events at the beginning of the queue.
  341. */
  342. export const queueEvents = async (events, socket, prepend) => {
  343. if (prepend) {
  344. // Drain the existing queue and place it after the given events.
  345. events = [
  346. ...events,
  347. ...Array.from({ length: event_queue.length }).map(() =>
  348. event_queue.shift(),
  349. ),
  350. ];
  351. }
  352. event_queue.push(...events.filter((e) => e !== undefined && e !== null));
  353. await processEvent(socket.current);
  354. };
  355. /**
  356. * Process an event off the event queue.
  357. * @param socket The socket object to send the event on.
  358. */
  359. export const processEvent = async (socket) => {
  360. // Only proceed if the socket is up and no event in the queue uses state, otherwise we throw the event into the void
  361. if (!socket && isStateful()) {
  362. return;
  363. }
  364. // Only proceed if we're not already processing an event.
  365. if (event_queue.length === 0 || event_processing) {
  366. return;
  367. }
  368. // Set processing to true to block other events from being processed.
  369. event_processing = true;
  370. // Apply the next event in the queue.
  371. const event = event_queue.shift();
  372. let eventSent = false;
  373. // Process events with handlers via REST and all others via websockets.
  374. if (event.handler) {
  375. eventSent = await applyRestEvent(event, socket);
  376. } else {
  377. eventSent = await applyEvent(event, socket);
  378. }
  379. // If no event was sent, set processing to false.
  380. if (!eventSent) {
  381. event_processing = false;
  382. // recursively call processEvent to drain the queue, since there is
  383. // no state update to trigger the useEffect event loop.
  384. await processEvent(socket);
  385. }
  386. };
  387. /**
  388. * Connect to a websocket and set the handlers.
  389. * @param socket The socket object to connect.
  390. * @param dispatch The function to queue state update
  391. * @param transports The transports to use.
  392. * @param setConnectErrors The function to update connection error value.
  393. * @param client_storage The client storage object from context.js
  394. */
  395. export const connect = async (
  396. socket,
  397. dispatch,
  398. transports,
  399. setConnectErrors,
  400. client_storage = {},
  401. ) => {
  402. // Get backend URL object from the endpoint.
  403. const endpoint = getBackendURL(EVENTURL);
  404. // Create the socket.
  405. socket.current = io(endpoint.href, {
  406. path: endpoint["pathname"],
  407. transports: transports,
  408. protocols: [reflexEnvironment.version],
  409. autoUnref: false,
  410. });
  411. // Ensure undefined fields in events are sent as null instead of removed
  412. socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v);
  413. socket.current.io.decoder.tryParse = (str) => {
  414. try {
  415. return JSON5.parse(str);
  416. } catch (e) {
  417. return false;
  418. }
  419. };
  420. function checkVisibility() {
  421. if (document.visibilityState === "visible") {
  422. if (!socket.current.connected) {
  423. console.log("Socket is disconnected, attempting to reconnect ");
  424. socket.current.connect();
  425. } else {
  426. console.log("Socket is reconnected ");
  427. }
  428. }
  429. }
  430. const disconnectTrigger = (event) => {
  431. if (socket.current?.connected) {
  432. console.log("Disconnect websocket on unload");
  433. socket.current.disconnect();
  434. }
  435. };
  436. const pagehideHandler = (event) => {
  437. if (event.persisted && socket.current?.connected) {
  438. console.log("Disconnect backend before bfcache on navigation");
  439. socket.current.disconnect();
  440. }
  441. };
  442. // Once the socket is open, hydrate the page.
  443. socket.current.on("connect", () => {
  444. setConnectErrors([]);
  445. window.addEventListener("pagehide", pagehideHandler);
  446. window.addEventListener("beforeunload", disconnectTrigger);
  447. window.addEventListener("unload", disconnectTrigger);
  448. });
  449. socket.current.on("connect_error", (error) => {
  450. setConnectErrors((connectErrors) => [connectErrors.slice(-9), error]);
  451. });
  452. // When the socket disconnects reset the event_processing flag
  453. socket.current.on("disconnect", () => {
  454. event_processing = false;
  455. window.removeEventListener("unload", disconnectTrigger);
  456. window.removeEventListener("beforeunload", disconnectTrigger);
  457. window.removeEventListener("pagehide", pagehideHandler);
  458. });
  459. // On each received message, queue the updates and events.
  460. socket.current.on("event", async (update) => {
  461. for (const substate in update.delta) {
  462. dispatch[substate](update.delta[substate]);
  463. }
  464. applyClientStorageDelta(client_storage, update.delta);
  465. event_processing = !update.final;
  466. if (update.events) {
  467. queueEvents(update.events, socket);
  468. }
  469. });
  470. socket.current.on("reload", async (event) => {
  471. event_processing = false;
  472. queueEvents([...initialEvents(), event], socket, true);
  473. });
  474. document.addEventListener("visibilitychange", checkVisibility);
  475. };
  476. /**
  477. * Upload files to the server.
  478. *
  479. * @param state The state to apply the delta to.
  480. * @param handler The handler to use.
  481. * @param upload_id The upload id to use.
  482. * @param on_upload_progress The function to call on upload progress.
  483. * @param socket the websocket connection
  484. *
  485. * @returns The response from posting to the UPLOADURL endpoint.
  486. */
  487. export const uploadFiles = async (
  488. handler,
  489. files,
  490. upload_id,
  491. on_upload_progress,
  492. socket,
  493. ) => {
  494. // return if there's no file to upload
  495. if (files === undefined || files.length === 0) {
  496. return false;
  497. }
  498. const upload_ref_name = `__upload_controllers_${upload_id}`;
  499. if (refs[upload_ref_name]) {
  500. console.log("Upload already in progress for ", upload_id);
  501. return false;
  502. }
  503. // Track how many partial updates have been processed for this upload.
  504. let resp_idx = 0;
  505. const eventHandler = (progressEvent) => {
  506. const event_callbacks = socket._callbacks.$event;
  507. // Whenever called, responseText will contain the entire response so far.
  508. const chunks = progressEvent.event.target.responseText.trim().split("\n");
  509. // So only process _new_ chunks beyond resp_idx.
  510. chunks.slice(resp_idx).map((chunk_json) => {
  511. try {
  512. const chunk = JSON5.parse(chunk_json);
  513. event_callbacks.map((f, ix) => {
  514. f(chunk)
  515. .then(() => {
  516. if (ix === event_callbacks.length - 1) {
  517. // Mark this chunk as processed.
  518. resp_idx += 1;
  519. }
  520. })
  521. .catch((e) => {
  522. if (progressEvent.progress === 1) {
  523. // Chunk may be incomplete, so only report errors when full response is available.
  524. console.log("Error processing chunk", chunk, e);
  525. }
  526. return;
  527. });
  528. });
  529. } catch (e) {
  530. if (progressEvent.progress === 1) {
  531. console.log("Error parsing chunk", chunk_json, e);
  532. }
  533. return;
  534. }
  535. });
  536. };
  537. const controller = new AbortController();
  538. const config = {
  539. headers: {
  540. "Reflex-Client-Token": getToken(),
  541. "Reflex-Event-Handler": handler,
  542. },
  543. signal: controller.signal,
  544. onDownloadProgress: eventHandler,
  545. };
  546. if (on_upload_progress) {
  547. config["onUploadProgress"] = on_upload_progress;
  548. }
  549. const formdata = new FormData();
  550. // Add the token and handler to the file name.
  551. files.forEach((file) => {
  552. formdata.append("files", file, file.path || file.name);
  553. });
  554. // Send the file to the server.
  555. refs[upload_ref_name] = controller;
  556. try {
  557. return await axios.post(getBackendURL(UPLOADURL), formdata, config);
  558. } catch (error) {
  559. if (error.response) {
  560. // The request was made and the server responded with a status code
  561. // that falls out of the range of 2xx
  562. console.log(error.response.data);
  563. } else if (error.request) {
  564. // The request was made but no response was received
  565. // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
  566. // http.ClientRequest in node.js
  567. console.log(error.request);
  568. } else {
  569. // Something happened in setting up the request that triggered an Error
  570. console.log(error.message);
  571. }
  572. return false;
  573. } finally {
  574. delete refs[upload_ref_name];
  575. }
  576. };
  577. /**
  578. * Create an event object.
  579. * @param {string} name The name of the event.
  580. * @param {Object.<string, Any>} payload The payload of the event.
  581. * @param {Object.<string, (number|boolean)>} event_actions The actions to take on the event.
  582. * @param {string} handler The client handler to process event.
  583. * @returns The event object.
  584. */
  585. export const Event = (
  586. name,
  587. payload = {},
  588. event_actions = {},
  589. handler = null,
  590. ) => {
  591. return { name, payload, handler, event_actions };
  592. };
  593. /**
  594. * Package client-side storage values as payload to send to the
  595. * backend with the hydrate event
  596. * @param client_storage The client storage object from context.js
  597. * @returns payload dict of client storage values
  598. */
  599. export const hydrateClientStorage = (client_storage) => {
  600. const client_storage_values = {};
  601. if (client_storage.cookies) {
  602. for (const state_key in client_storage.cookies) {
  603. const cookie_options = client_storage.cookies[state_key];
  604. const cookie_name = cookie_options.name || state_key;
  605. const cookie_value = cookies.get(cookie_name);
  606. if (cookie_value !== undefined) {
  607. client_storage_values[state_key] = cookies.get(cookie_name);
  608. }
  609. }
  610. }
  611. if (client_storage.local_storage && typeof window !== "undefined") {
  612. for (const state_key in client_storage.local_storage) {
  613. const options = client_storage.local_storage[state_key];
  614. const local_storage_value = localStorage.getItem(
  615. options.name || state_key,
  616. );
  617. if (local_storage_value !== null) {
  618. client_storage_values[state_key] = local_storage_value;
  619. }
  620. }
  621. }
  622. if (client_storage.session_storage && typeof window != "undefined") {
  623. for (const state_key in client_storage.session_storage) {
  624. const session_options = client_storage.session_storage[state_key];
  625. const session_storage_value = sessionStorage.getItem(
  626. session_options.name || state_key,
  627. );
  628. if (session_storage_value != null) {
  629. client_storage_values[state_key] = session_storage_value;
  630. }
  631. }
  632. }
  633. if (
  634. client_storage.cookies ||
  635. client_storage.local_storage ||
  636. client_storage.session_storage
  637. ) {
  638. return client_storage_values;
  639. }
  640. return {};
  641. };
  642. /**
  643. * Update client storage values based on backend state delta.
  644. * @param client_storage The client storage object from context.js
  645. * @param delta The state update from the backend
  646. */
  647. const applyClientStorageDelta = (client_storage, delta) => {
  648. // find the main state and check for is_hydrated
  649. const unqualified_states = Object.keys(delta).filter(
  650. (key) => key.split(".").length === 1,
  651. );
  652. if (unqualified_states.length === 1) {
  653. const main_state = delta[unqualified_states[0]];
  654. if (main_state.is_hydrated !== undefined && !main_state.is_hydrated) {
  655. // skip if the state is not hydrated yet, since all client storage
  656. // values are sent in the hydrate event
  657. return;
  658. }
  659. }
  660. // Save known client storage values to cookies and localStorage.
  661. for (const substate in delta) {
  662. for (const key in delta[substate]) {
  663. const state_key = `${substate}.${key}`;
  664. if (client_storage.cookies && state_key in client_storage.cookies) {
  665. const cookie_options = { ...client_storage.cookies[state_key] };
  666. const cookie_name = cookie_options.name || state_key;
  667. delete cookie_options.name; // name is not a valid cookie option
  668. cookies.set(cookie_name, delta[substate][key], cookie_options);
  669. } else if (
  670. client_storage.local_storage &&
  671. state_key in client_storage.local_storage &&
  672. typeof window !== "undefined"
  673. ) {
  674. const options = client_storage.local_storage[state_key];
  675. localStorage.setItem(options.name || state_key, delta[substate][key]);
  676. } else if (
  677. client_storage.session_storage &&
  678. state_key in client_storage.session_storage &&
  679. typeof window !== "undefined"
  680. ) {
  681. const session_options = client_storage.session_storage[state_key];
  682. sessionStorage.setItem(
  683. session_options.name || state_key,
  684. delta[substate][key],
  685. );
  686. }
  687. }
  688. }
  689. };
  690. /**
  691. * Establish websocket event loop for a NextJS page.
  692. * @param dispatch The reducer dispatch function to update state.
  693. * @param initial_events The initial app events.
  694. * @param client_storage The client storage object from context.js
  695. *
  696. * @returns [addEvents, connectErrors] -
  697. * addEvents is used to queue an event, and
  698. * connectErrors is an array of reactive js error from the websocket connection (or null if connected).
  699. */
  700. export const useEventLoop = (
  701. dispatch,
  702. initial_events = () => [],
  703. client_storage = {},
  704. ) => {
  705. const socket = useRef(null);
  706. const router = useRouter();
  707. const [connectErrors, setConnectErrors] = useState([]);
  708. // Function to add new events to the event queue.
  709. const addEvents = (events, args, event_actions) => {
  710. const _events = events.filter((e) => e !== undefined && e !== null);
  711. if (!(args instanceof Array)) {
  712. args = [args];
  713. }
  714. event_actions = _events.reduce(
  715. (acc, e) => ({ ...acc, ...e.event_actions }),
  716. event_actions ?? {},
  717. );
  718. const _e = args.filter((o) => o?.preventDefault !== undefined)[0];
  719. if (event_actions?.preventDefault && _e?.preventDefault) {
  720. _e.preventDefault();
  721. }
  722. if (event_actions?.stopPropagation && _e?.stopPropagation) {
  723. _e.stopPropagation();
  724. }
  725. const combined_name = _events.map((e) => e.name).join("+++");
  726. if (event_actions?.temporal) {
  727. if (!socket.current || !socket.current.connected) {
  728. return; // don't queue when the backend is not connected
  729. }
  730. }
  731. if (event_actions?.throttle) {
  732. // If throttle returns false, the events are not added to the queue.
  733. if (!throttle(combined_name, event_actions.throttle)) {
  734. return;
  735. }
  736. }
  737. if (event_actions?.debounce) {
  738. // If debounce is used, queue the events after some delay
  739. debounce(
  740. combined_name,
  741. () => queueEvents(_events, socket),
  742. event_actions.debounce,
  743. );
  744. } else {
  745. queueEvents(_events, socket);
  746. }
  747. };
  748. const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
  749. useEffect(() => {
  750. if (router.isReady && !sentHydrate.current) {
  751. queueEvents(
  752. initial_events().map((e) => ({
  753. ...e,
  754. router_data: (({ pathname, query, asPath }) => ({
  755. pathname,
  756. query,
  757. asPath,
  758. }))(router),
  759. })),
  760. socket,
  761. true,
  762. );
  763. sentHydrate.current = true;
  764. }
  765. }, [router.isReady]);
  766. // Handle frontend errors and send them to the backend via websocket.
  767. useEffect(() => {
  768. if (typeof window === "undefined") {
  769. return;
  770. }
  771. window.onerror = function (msg, url, lineNo, columnNo, error) {
  772. addEvents([
  773. Event(`${exception_state_name}.handle_frontend_exception`, {
  774. stack: error.stack,
  775. component_stack: "",
  776. }),
  777. ]);
  778. return false;
  779. };
  780. //NOTE: Only works in Chrome v49+
  781. //https://github.com/mknichel/javascript-errors?tab=readme-ov-file#promise-rejection-events
  782. window.onunhandledrejection = function (event) {
  783. addEvents([
  784. Event(`${exception_state_name}.handle_frontend_exception`, {
  785. stack: event.reason?.stack,
  786. component_stack: "",
  787. }),
  788. ]);
  789. return false;
  790. };
  791. }, []);
  792. // Handle socket connect/disconnect.
  793. useEffect(() => {
  794. // only use websockets if state is present and backend is not disabled (reflex cloud).
  795. if (Object.keys(initialState).length > 1 && !isBackendDisabled()) {
  796. // Initialize the websocket connection.
  797. if (!socket.current) {
  798. connect(
  799. socket,
  800. dispatch,
  801. ["websocket"],
  802. setConnectErrors,
  803. client_storage,
  804. );
  805. }
  806. }
  807. // Cleanup function.
  808. return () => {
  809. if (socket.current) {
  810. socket.current.disconnect();
  811. }
  812. };
  813. }, []);
  814. // Main event loop.
  815. useEffect(() => {
  816. // Skip if the router is not ready.
  817. if (!router.isReady || isBackendDisabled()) {
  818. return;
  819. }
  820. (async () => {
  821. // Process all outstanding events.
  822. while (event_queue.length > 0 && !event_processing) {
  823. await processEvent(socket.current);
  824. }
  825. })();
  826. });
  827. // localStorage event handling
  828. useEffect(() => {
  829. const storage_to_state_map = {};
  830. if (client_storage.local_storage && typeof window !== "undefined") {
  831. for (const state_key in client_storage.local_storage) {
  832. const options = client_storage.local_storage[state_key];
  833. if (options.sync) {
  834. const local_storage_value_key = options.name || state_key;
  835. storage_to_state_map[local_storage_value_key] = state_key;
  836. }
  837. }
  838. }
  839. // e is StorageEvent
  840. const handleStorage = (e) => {
  841. if (storage_to_state_map[e.key]) {
  842. const vars = {};
  843. vars[storage_to_state_map[e.key]] = e.newValue;
  844. const event = Event(
  845. `${state_name}.reflex___state____update_vars_internal_state.update_vars_internal`,
  846. { vars: vars },
  847. );
  848. addEvents([event], e);
  849. }
  850. };
  851. window.addEventListener("storage", handleStorage);
  852. return () => window.removeEventListener("storage", handleStorage);
  853. });
  854. // Route after the initial page hydration.
  855. useEffect(() => {
  856. const change_start = () => {
  857. const main_state_dispatch = dispatch["reflex___state____state"];
  858. if (main_state_dispatch !== undefined) {
  859. main_state_dispatch({ is_hydrated: false });
  860. }
  861. };
  862. const change_complete = () => addEvents(onLoadInternalEvent());
  863. const change_error = () => {
  864. // Remove cached error state from router for this page, otherwise the
  865. // page will never send on_load events again.
  866. if (router.components[router.pathname].error) {
  867. delete router.components[router.pathname].error;
  868. }
  869. };
  870. router.events.on("routeChangeStart", change_start);
  871. router.events.on("routeChangeComplete", change_complete);
  872. router.events.on("routeChangeError", change_error);
  873. return () => {
  874. router.events.off("routeChangeStart", change_start);
  875. router.events.off("routeChangeComplete", change_complete);
  876. router.events.off("routeChangeError", change_error);
  877. };
  878. }, [router]);
  879. return [addEvents, connectErrors];
  880. };
  881. /***
  882. * Check if a value is truthy in python.
  883. * @param val The value to check.
  884. * @returns True if the value is truthy, false otherwise.
  885. */
  886. export const isTrue = (val) => {
  887. if (Array.isArray(val)) return val.length > 0;
  888. if (val === Object(val)) return Object.keys(val).length > 0;
  889. return Boolean(val);
  890. };
  891. /***
  892. * Check if a value is not null or undefined.
  893. * @param val The value to check.
  894. * @returns True if the value is not null or undefined, false otherwise.
  895. */
  896. export const isNotNullOrUndefined = (val) => {
  897. return (val ?? undefined) !== undefined;
  898. };
  899. /**
  900. * Get the value from a ref.
  901. * @param ref The ref to get the value from.
  902. * @returns The value.
  903. */
  904. export const getRefValue = (ref) => {
  905. if (!ref || !ref.current) {
  906. return;
  907. }
  908. if (ref.current.type == "checkbox") {
  909. return ref.current.checked; // chakra
  910. } else if (
  911. ref.current.className?.includes("rt-CheckboxRoot") ||
  912. ref.current.className?.includes("rt-SwitchRoot")
  913. ) {
  914. return ref.current.ariaChecked == "true"; // radix
  915. } else if (ref.current.className?.includes("rt-SliderRoot")) {
  916. // find the actual slider
  917. return ref.current.querySelector(".rt-SliderThumb")?.ariaValueNow;
  918. } else {
  919. //querySelector(":checked") is needed to get value from radio_group
  920. return (
  921. ref.current.value ||
  922. (ref.current.querySelector &&
  923. ref.current.querySelector(":checked") &&
  924. ref.current.querySelector(":checked")?.value)
  925. );
  926. }
  927. };
  928. /**
  929. * Get the values from a ref array.
  930. * @param refs The refs to get the values from.
  931. * @returns The values array.
  932. */
  933. export const getRefValues = (refs) => {
  934. if (!refs) {
  935. return;
  936. }
  937. // getAttribute is used by RangeSlider because it doesn't assign value
  938. return refs.map((ref) =>
  939. ref.current
  940. ? ref.current.value || ref.current.getAttribute("aria-valuenow")
  941. : null,
  942. );
  943. };
  944. /**
  945. * Spread two arrays or two objects.
  946. * @param first The first array or object.
  947. * @param second The second array or object.
  948. * @returns The final merged array or object.
  949. */
  950. export const spreadArraysOrObjects = (first, second) => {
  951. if (Array.isArray(first) && Array.isArray(second)) {
  952. return [...first, ...second];
  953. } else if (typeof first === "object" && typeof second === "object") {
  954. return { ...first, ...second };
  955. } else {
  956. throw new Error("Both parameters must be either arrays or objects.");
  957. }
  958. };