From 2272cc0b5d861615c661b51ba03096e6b380fb96 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 2 Mar 2026 01:00:28 +0100 Subject: [PATCH 01/10] wip --- pyi_hashes.json | 212 ++++++++--------- reflex/.templates/web/generate-shell.mjs | 35 +++ reflex/.templates/web/ssr-serve.js | 76 ++++++ reflex/.templates/web/utils/state.js | 29 ++- reflex/app.py | 164 ++++++++++++- reflex/compiler/compiler.py | 28 ++- reflex/compiler/templates.py | 109 ++++++++- reflex/config.py | 3 + reflex/constants/base.py | 5 +- reflex/constants/event.py | 1 + reflex/constants/installer.py | 7 +- reflex/route.py | 33 +++ reflex/utils/build.py | 72 +++++- reflex/utils/frontend_skeleton.py | 25 +- tests/units/test_prerequisites.py | 16 ++ tests/units/test_ssr_compile.py | 267 ++++++++++++++++++++++ tests/units/test_ssr_data.py | 279 +++++++++++++++++++++++ 17 files changed, 1214 insertions(+), 147 deletions(-) create mode 100644 reflex/.templates/web/generate-shell.mjs create mode 100644 reflex/.templates/web/ssr-serve.js create mode 100644 tests/units/test_ssr_compile.py create mode 100644 tests/units/test_ssr_data.py diff --git a/pyi_hashes.json b/pyi_hashes.json index ec9bbff8850..0b2c93927f2 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -2,121 +2,121 @@ "reflex/__init__.pyi": "0a3ae880e256b9fd3b960e12a2cb51a7", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", - "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", - "reflex/components/base/body.pyi": "e8ab029a730824bab6d4211203609e6a", - "reflex/components/base/document.pyi": "311c53c90a60587a82e760103758a3cf", - "reflex/components/base/error_boundary.pyi": "a678cceea014cb16048647257cd24ba6", - "reflex/components/base/fragment.pyi": "745f1be02c23a0b25d7c52d7423ec76a", - "reflex/components/base/link.pyi": "0bc1d26ee29d8864aed14a12991bd47d", - "reflex/components/base/meta.pyi": "129aecf65ab53f756c4d1cbe1d0b188d", - "reflex/components/base/script.pyi": "e5f506d1d0d6712cb9e597a781eb3941", - "reflex/components/base/strict_mode.pyi": "6b72e16caadf7158ab744a0ab751b010", + "reflex/components/base/app_wrap.pyi": "e39ea07ef30b1989376a326ffcc36296", + "reflex/components/base/body.pyi": "c9124f9d81839aca65c6a58dd1b1213f", + "reflex/components/base/document.pyi": "aa94fb557bc3999f216b6124b78a37f5", + "reflex/components/base/error_boundary.pyi": "93422a1c80b459b48ef824c735aa9552", + "reflex/components/base/fragment.pyi": "f2bf3351646f43e2a0cae6100e506a43", + "reflex/components/base/link.pyi": "9fea3e2b04f662ac84f821ddaba75049", + "reflex/components/base/meta.pyi": "7effa17dc9f151182fe83c82fb32a78b", + "reflex/components/base/script.pyi": "5b40f0ba967495bc49dd3ec0577e2b55", + "reflex/components/base/strict_mode.pyi": "724635b7543ccf1c41d83951074ead47", "reflex/components/core/__init__.pyi": "007170b97e58bdf28b2aee381d91c0c7", - "reflex/components/core/auto_scroll.pyi": "18068d22aca7244a08cd0c5a897c0950", - "reflex/components/core/banner.pyi": "fd93e7a92961de8524718ad32135c37c", - "reflex/components/core/clipboard.pyi": "a844eb927d9bc2a43f5e88161b258539", - "reflex/components/core/debounce.pyi": "055da7aa890f44fb4d48bd5978f1a874", - "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", - "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", - "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", - "reflex/components/core/upload.pyi": "6dc28804a6dddf903e31162e87c1b023", - "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", + "reflex/components/core/auto_scroll.pyi": "7d0d4b5271ee0759b4885802959dd6f4", + "reflex/components/core/banner.pyi": "01317be6f0e1ae96238a2d1ed0f4b204", + "reflex/components/core/clipboard.pyi": "22e685d6b8442bfd5fc5796e56bc9682", + "reflex/components/core/debounce.pyi": "ee09e8f946d67838be1ace268e6729f7", + "reflex/components/core/helmet.pyi": "c8c1bdf8e2be8f467f7fad6413561e9f", + "reflex/components/core/html.pyi": "8d470113864320f28d4cbc68e85bfc47", + "reflex/components/core/sticky.pyi": "3f96767ee672644ad7a182090d914040", + "reflex/components/core/upload.pyi": "4d854de28f5138d3fbbd6ce99017f11b", + "reflex/components/core/window_events.pyi": "d4e34bc4aa4c642c661bf1fb85ab2ed2", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", - "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", - "reflex/components/datadisplay/dataeditor.pyi": "f8c1e816c9f22f4a7429f812214407f2", - "reflex/components/datadisplay/shiki_code_block.pyi": "1d53e75b6be0d3385a342e7b3011babd", + "reflex/components/datadisplay/code.pyi": "72f5b0315bd21fbfdacf4e675b953ac2", + "reflex/components/datadisplay/dataeditor.pyi": "bb24db26c3a86c0509fd88a8df37128d", + "reflex/components/datadisplay/shiki_code_block.pyi": "6c9fcaf8e428acf8f77096eab1723d3d", "reflex/components/el/__init__.pyi": "0adfd001a926a2a40aee94f6fa725ecc", - "reflex/components/el/element.pyi": "c5974a92fbc310e42d0f6cfdd13472f4", + "reflex/components/el/element.pyi": "a14561a3018c8c64fcdb64b467efccc2", "reflex/components/el/elements/__init__.pyi": "29512d7a6b29c6dc5ff68d3b31f26528", - "reflex/components/el/elements/base.pyi": "3f74c7ea573ea29b055b0cd48b040d2c", - "reflex/components/el/elements/forms.pyi": "8b6bb2fbaf4bad828b076e2f7c8444d0", - "reflex/components/el/elements/inline.pyi": "3549cd6ad45217aa6387800911b641c3", - "reflex/components/el/elements/media.pyi": "9b97220aa99783d402b6e278c4069043", - "reflex/components/el/elements/metadata.pyi": "24448004b7aa07f1225028a85bd49fef", - "reflex/components/el/elements/other.pyi": "0c4d5d0b955d8596bf6cf4a48d7decdb", - "reflex/components/el/elements/scripts.pyi": "d33df9f21f7e838376b2b5024beef7c9", - "reflex/components/el/elements/sectioning.pyi": "3c5a7e4caa9c25da0ae788f02466eac4", - "reflex/components/el/elements/tables.pyi": "686eb70ea7d8c4dafb0cc5c284e76184", - "reflex/components/el/elements/typography.pyi": "684e83dde887dba12badd0fb75c87c04", - "reflex/components/gridjs/datatable.pyi": "98a7e1b3f3b60cafcdfcd8879750ee42", - "reflex/components/lucide/icon.pyi": "dcb8773ef162f3ec5759efe11374cf5e", - "reflex/components/markdown/markdown.pyi": "dd74e8e9665b2a813ff799a7aa190b44", - "reflex/components/moment/moment.pyi": "e1952f1c2c82cef85d91e970d1be64ab", - "reflex/components/plotly/plotly.pyi": "4311a0aae2abcc9226abb6a273f96372", + "reflex/components/el/elements/base.pyi": "7c272b0fd1172e1840a66975f5df51a9", + "reflex/components/el/elements/forms.pyi": "d5d57536de6eedb9d13a1027f8621040", + "reflex/components/el/elements/inline.pyi": "d286cbe92244d624276b3d799809775f", + "reflex/components/el/elements/media.pyi": "66e9e478b61a1baff2b4d7cc75829a31", + "reflex/components/el/elements/metadata.pyi": "007a6c99cba81a8b8a9e007de94695a0", + "reflex/components/el/elements/other.pyi": "f392a93d23de3a78f872f06fbc403cb2", + "reflex/components/el/elements/scripts.pyi": "38085cfbf36fe98abbbb677096cdd730", + "reflex/components/el/elements/sectioning.pyi": "3ea78aa78769736806918e580f10990b", + "reflex/components/el/elements/tables.pyi": "0a2f098afa0b721fc47aedb0ed5e820a", + "reflex/components/el/elements/typography.pyi": "45deb16a45ecd54f83e85cdcdadb6eff", + "reflex/components/gridjs/datatable.pyi": "f0032aa28d3adbc50f3fe6f78ae1e13f", + "reflex/components/lucide/icon.pyi": "66ce989b327081bb5fb99d88ecfd5aa2", + "reflex/components/markdown/markdown.pyi": "6de3ce56d873c34e954491f5a8469329", + "reflex/components/moment/moment.pyi": "32a17f6324e3805ecbf3ff3a862704b6", + "reflex/components/plotly/plotly.pyi": "f337888586f51463da1e7886ca84a40b", "reflex/components/radix/__init__.pyi": "5d8e3579912473e563676bfc71f29191", "reflex/components/radix/primitives/__init__.pyi": "01c388fe7a1f5426a16676404344edf6", - "reflex/components/radix/primitives/accordion.pyi": "19484eca0ad53f538f5db04c09921738", - "reflex/components/radix/primitives/base.pyi": "9ef34884fb6028dc017df5e2db639c81", - "reflex/components/radix/primitives/dialog.pyi": "9ee73362bb59619c482b6b0d07033f37", - "reflex/components/radix/primitives/drawer.pyi": "921e45dfaf5b9131ef27c561c3acca2e", - "reflex/components/radix/primitives/form.pyi": "78055e820703c98c3b838aa889566365", - "reflex/components/radix/primitives/progress.pyi": "c917952d57ddb3e138a40c4005120d5e", - "reflex/components/radix/primitives/slider.pyi": "4ff06f0025d47f166132909b09ab96f8", + "reflex/components/radix/primitives/accordion.pyi": "dd6c588ffeb3d571bd511401a4be4b88", + "reflex/components/radix/primitives/base.pyi": "1206ab73a5d6783e8476c9069f6fdadd", + "reflex/components/radix/primitives/dialog.pyi": "5344733f183a8926714953f58c7eff22", + "reflex/components/radix/primitives/drawer.pyi": "a640e0849f3a9c386edadba468a82761", + "reflex/components/radix/primitives/form.pyi": "afa914dbb20222dd1d27b61903e05490", + "reflex/components/radix/primitives/progress.pyi": "213896963b9f3c920c0d1e344e9cca3e", + "reflex/components/radix/primitives/slider.pyi": "0f80ec7a9b4a04e94f0dc8bc602bae0a", "reflex/components/radix/themes/__init__.pyi": "582b4a7ead62b2ae8605e17fa084c063", - "reflex/components/radix/themes/base.pyi": "3e1ccd5ce5fef0b2898025193ee3d069", - "reflex/components/radix/themes/color_mode.pyi": "dda570583355d8c0d8f607be457ba7a1", + "reflex/components/radix/themes/base.pyi": "b37be1a57015f405fdd4777c448474b3", + "reflex/components/radix/themes/color_mode.pyi": "3e5c86200feb8a99bf03a2fbe58a7718", "reflex/components/radix/themes/components/__init__.pyi": "efa279ee05479d7bb8a64d49da808d03", - "reflex/components/radix/themes/components/alert_dialog.pyi": "eed422fcc1ff5ccf3dbf6934699bd0b1", - "reflex/components/radix/themes/components/aspect_ratio.pyi": "71de4160d79840561c48b570197a4152", - "reflex/components/radix/themes/components/avatar.pyi": "e40c2f0fda6d2c028d83681a27f3fb96", - "reflex/components/radix/themes/components/badge.pyi": "58fd1a9c5d2f8762e2a0370311731ff5", - "reflex/components/radix/themes/components/button.pyi": "50f0b08ad5d1d1054ab537152f0f5c43", - "reflex/components/radix/themes/components/callout.pyi": "547f2570ffbd10db36b745566e9f1b17", - "reflex/components/radix/themes/components/card.pyi": "f7adb83f7b001a11bdd7fd6791fb3ffb", - "reflex/components/radix/themes/components/checkbox.pyi": "8eabb6887a5d0849a43e086a284814c2", - "reflex/components/radix/themes/components/checkbox_cards.pyi": "1d567fd04b4425abd5cc5aad10108aa9", - "reflex/components/radix/themes/components/checkbox_group.pyi": "8638582a623036f8893a3fa6080f2672", - "reflex/components/radix/themes/components/context_menu.pyi": "b9499d8bdd2c5565621fea5fe7d7a25a", - "reflex/components/radix/themes/components/data_list.pyi": "6f8d9c582e084c23966b992158193b72", - "reflex/components/radix/themes/components/dialog.pyi": "d2615f1a68c80ff930444d054b598c13", - "reflex/components/radix/themes/components/dropdown_menu.pyi": "43f8770c9adf93c73398d68f79048424", - "reflex/components/radix/themes/components/hover_card.pyi": "a96f4433237f9994decf935deff9f269", - "reflex/components/radix/themes/components/icon_button.pyi": "e930911d8ecbe61e5447e61c76a28ab6", - "reflex/components/radix/themes/components/inset.pyi": "bd7a2186b553bd4c86d83ff50c784066", - "reflex/components/radix/themes/components/popover.pyi": "91f8edefeb232cc6d48690b1838144c2", - "reflex/components/radix/themes/components/progress.pyi": "0e59587d5b3c8fe0d0067587f144e5b0", - "reflex/components/radix/themes/components/radio.pyi": "f375aa5ac746679618ea7dad257e3224", - "reflex/components/radix/themes/components/radio_cards.pyi": "9dc34a1ce2a1924eb1f41438ef84e80b", - "reflex/components/radix/themes/components/radio_group.pyi": "173254cf91908bcf6aa4fa21a747e2cf", - "reflex/components/radix/themes/components/scroll_area.pyi": "2e3539b0f6895dda127ee96e9864dbf9", - "reflex/components/radix/themes/components/segmented_control.pyi": "1776f1ad936bae402007802b1ee98906", - "reflex/components/radix/themes/components/select.pyi": "2c7aee592972ff5f05da08154aa981c8", - "reflex/components/radix/themes/components/separator.pyi": "79e550cc10ee455f35d75d0e236fedd2", - "reflex/components/radix/themes/components/skeleton.pyi": "a25d3ceb56f99f736ea463579845c454", - "reflex/components/radix/themes/components/slider.pyi": "305a34c14ca8656ca9267e4c31aaa388", - "reflex/components/radix/themes/components/spinner.pyi": "b7e689e7d75635e379242fd113a1ea9a", - "reflex/components/radix/themes/components/switch.pyi": "f1ba948750a74126cda990e89a3ec7ef", - "reflex/components/radix/themes/components/table.pyi": "eefbbd1904deae3d166fcad28b20fd4a", - "reflex/components/radix/themes/components/tabs.pyi": "a533d2509a6798fe0ab7275b0152519d", - "reflex/components/radix/themes/components/text_area.pyi": "4af55e5d18a5b9d56717bf31b23ea543", - "reflex/components/radix/themes/components/text_field.pyi": "232618b744076db98d861ea1b9eb3192", - "reflex/components/radix/themes/components/tooltip.pyi": "2b8366200ce92ec4784ca3ec4152e676", + "reflex/components/radix/themes/components/alert_dialog.pyi": "18f602447492a47b577680ed7b5567fb", + "reflex/components/radix/themes/components/aspect_ratio.pyi": "f1b976613b5b12edcc80be730511b04f", + "reflex/components/radix/themes/components/avatar.pyi": "8e3797ab7df3fca0d9092d96aee84d61", + "reflex/components/radix/themes/components/badge.pyi": "8b51f4ed128fa13ee5ab2f682b0b3176", + "reflex/components/radix/themes/components/button.pyi": "d55ce7bdea1403e6a0efa1dbfc651a61", + "reflex/components/radix/themes/components/callout.pyi": "138e6e1def9c9153f71b0a018cb6c4ce", + "reflex/components/radix/themes/components/card.pyi": "e840f93caaea8a0c76c26b4035fdcb64", + "reflex/components/radix/themes/components/checkbox.pyi": "74b0293a653eb0933e8bd5e27d4c805e", + "reflex/components/radix/themes/components/checkbox_cards.pyi": "3d0fd3bdce49e94b0ad8d0c726e7a941", + "reflex/components/radix/themes/components/checkbox_group.pyi": "d858f46332e1f1f9ff3200399c9520a5", + "reflex/components/radix/themes/components/context_menu.pyi": "6052570ea73008270486bccc23631bc8", + "reflex/components/radix/themes/components/data_list.pyi": "9f0635ca222dbeebd5be235f8d4b3e8d", + "reflex/components/radix/themes/components/dialog.pyi": "4f49cd0ffee79f3caba93042df56be28", + "reflex/components/radix/themes/components/dropdown_menu.pyi": "0d5a228ea7d81470ff85ae2333ec5b07", + "reflex/components/radix/themes/components/hover_card.pyi": "28033f1354579725e6ece952758ddbc7", + "reflex/components/radix/themes/components/icon_button.pyi": "61d131968283ff9bf2e70054ac3e1a5d", + "reflex/components/radix/themes/components/inset.pyi": "8d4b238c5c5d4d72254889a7b7adc016", + "reflex/components/radix/themes/components/popover.pyi": "49aef73b29362ee4c2ef4ccfdbcc3adc", + "reflex/components/radix/themes/components/progress.pyi": "8a32157eb666f8e47c9716acb119683d", + "reflex/components/radix/themes/components/radio.pyi": "a1f55d90f75dbaf36c03b4c57dfb1e00", + "reflex/components/radix/themes/components/radio_cards.pyi": "bbbe53ffdb72bbab2b635f54745b9fc8", + "reflex/components/radix/themes/components/radio_group.pyi": "28a6d9ac8f8d3c3b3402430bcc083a50", + "reflex/components/radix/themes/components/scroll_area.pyi": "caff5f39bc7fdad3aad750ae60c0171d", + "reflex/components/radix/themes/components/segmented_control.pyi": "d1afa3691578a36c6e4229757b0ca17a", + "reflex/components/radix/themes/components/select.pyi": "ed28086948f174ee8776b8481a3cd688", + "reflex/components/radix/themes/components/separator.pyi": "d40d4fe3c9b6d33dcadc4459cb5059f0", + "reflex/components/radix/themes/components/skeleton.pyi": "89d55ceec7aa035433c2a02432c15ace", + "reflex/components/radix/themes/components/slider.pyi": "5d9fd5475b1e545ca6850953647163d8", + "reflex/components/radix/themes/components/spinner.pyi": "016a46dc3ecdaf9bbdd404925babe3ca", + "reflex/components/radix/themes/components/switch.pyi": "2f260c4a33f05dc4d79e34a519e5f816", + "reflex/components/radix/themes/components/table.pyi": "7256e33b9afab10cb86e235b351a01d2", + "reflex/components/radix/themes/components/tabs.pyi": "fb9c38c1ee45b1c0806bc917141a2b87", + "reflex/components/radix/themes/components/text_area.pyi": "b95ee08c4af639b6c10e68b845baef8f", + "reflex/components/radix/themes/components/text_field.pyi": "c16732cfd8960cc395bbc0516f7819ef", + "reflex/components/radix/themes/components/tooltip.pyi": "7a1f5ccfac8088a61a880a8b19cc5990", "reflex/components/radix/themes/layout/__init__.pyi": "73eefc509a49215b1797b5b5d28d035e", - "reflex/components/radix/themes/layout/base.pyi": "5be31d7dadd23ab544e53762423d123e", - "reflex/components/radix/themes/layout/box.pyi": "dbaed1c50c668805fc7b71d22f878254", - "reflex/components/radix/themes/layout/center.pyi": "17323694217e8ad7611adb683f8d96ce", - "reflex/components/radix/themes/layout/container.pyi": "24222fd7ffa2dc05f709eab6c7b9643c", - "reflex/components/radix/themes/layout/flex.pyi": "0307e9dbe6a5784140121d77c8f67a86", - "reflex/components/radix/themes/layout/grid.pyi": "95c9edb8bdd4e39dc1bd6bc2a8ca0933", - "reflex/components/radix/themes/layout/list.pyi": "049ecf827ef0ba8de2d76dbf7b1c562c", - "reflex/components/radix/themes/layout/section.pyi": "a51952b9b5c8227aa3024373dedcad5d", - "reflex/components/radix/themes/layout/spacer.pyi": "c35accf0f2f742c90a23675ff1fb960d", - "reflex/components/radix/themes/layout/stack.pyi": "271d3315c6196356d3ced759520d4e7d", + "reflex/components/radix/themes/layout/base.pyi": "a63a07e11c6bc49c25b5da476d9ce38d", + "reflex/components/radix/themes/layout/box.pyi": "2421795671726996bd976a00d8d2378a", + "reflex/components/radix/themes/layout/center.pyi": "0c9c2d444f94f404ee12c5c482151b34", + "reflex/components/radix/themes/layout/container.pyi": "737db1b092eb6b424060d35d8789e225", + "reflex/components/radix/themes/layout/flex.pyi": "ae3cd66acaee576a4a24c070514e510c", + "reflex/components/radix/themes/layout/grid.pyi": "79ae4534482f47000bb035bd2f73ac79", + "reflex/components/radix/themes/layout/list.pyi": "5dfd59f0c5dae70b25a34372350449b6", + "reflex/components/radix/themes/layout/section.pyi": "19ccfe0ec887a340b62f6488dfb438e5", + "reflex/components/radix/themes/layout/spacer.pyi": "93c6346a609fd50482986278c8cb2428", + "reflex/components/radix/themes/layout/stack.pyi": "c34a7143f9fa188d9f05fdef89add9ab", "reflex/components/radix/themes/typography/__init__.pyi": "b8ef970530397e9984004961f3aaee62", - "reflex/components/radix/themes/typography/blockquote.pyi": "080c71899532f5dbf4cf143e7a5ad3bf", - "reflex/components/radix/themes/typography/code.pyi": "7ffe785d55979cf8ff97ea040f3e2b64", - "reflex/components/radix/themes/typography/heading.pyi": "0ebb38915cd0521fd59c569e04d288bb", - "reflex/components/radix/themes/typography/link.pyi": "e88c5d880a54548b6808c097ac62505b", - "reflex/components/radix/themes/typography/text.pyi": "50f9ca15a941e4b77ddd12e77aa3c03e", - "reflex/components/react_player/audio.pyi": "0e1690ff1f1f39bc748278d292238350", - "reflex/components/react_player/react_player.pyi": "5ccd373b94ed1d3934ae6afc46bd6fe4", - "reflex/components/react_player/video.pyi": "998671c06103d797c554d9278eb3b2a0", - "reflex/components/react_router/dom.pyi": "3042fa630b7e26a7378fe045d7fbf4af", + "reflex/components/radix/themes/typography/blockquote.pyi": "752543a6b5677527192894372271f405", + "reflex/components/radix/themes/typography/code.pyi": "bf554d2f5ac94cbf25a3eb8af0cc4c6c", + "reflex/components/radix/themes/typography/heading.pyi": "47d6861eb716c75e82d546fd8a26c793", + "reflex/components/radix/themes/typography/link.pyi": "4e3cad3a03933f6c8b1a61b9a96ce913", + "reflex/components/radix/themes/typography/text.pyi": "0218fb9f08667f88315cec27689dd084", + "reflex/components/react_player/audio.pyi": "e82dabd3316dbd4c08dfea4a4eaf6865", + "reflex/components/react_player/react_player.pyi": "a2b606bff3c4964a2bcb25cfa8ffa68d", + "reflex/components/react_player/video.pyi": "d02f2b6d5ff8ed328a01224439459ec8", + "reflex/components/react_router/dom.pyi": "e2c9489e73d35d0a069d7a4f81ff7f8b", "reflex/components/recharts/__init__.pyi": "6ee7f1ca2c0912f389ba6f3251a74d99", - "reflex/components/recharts/cartesian.pyi": "d138261ab8259d5208c2f028b9f708bd", - "reflex/components/recharts/charts.pyi": "013036b9c00ad85a570efdb813c1bc40", - "reflex/components/recharts/general.pyi": "d87ff9b85b2a204be01753690df4fb11", - "reflex/components/recharts/polar.pyi": "b8b1a3e996e066facdf4f8c9eb363137", - "reflex/components/recharts/recharts.pyi": "d5c9fc57a03b419748f0408c23319eee", - "reflex/components/sonner/toast.pyi": "3c27bad1aaeb5183eaa6a41e77e8d7f0" + "reflex/components/recharts/cartesian.pyi": "cc829e71a9b8b804a46195bbc1fea784", + "reflex/components/recharts/charts.pyi": "a866fe6c7add355a789981f6a0c6efa4", + "reflex/components/recharts/general.pyi": "ccabc476864c95cd61b705933fc341ac", + "reflex/components/recharts/polar.pyi": "4327e685e14b13ecc84380d455175a03", + "reflex/components/recharts/recharts.pyi": "fa19c2b678ed5994f261c500397b644d", + "reflex/components/sonner/toast.pyi": "f5f6a7f0f690ebd19f4405aca28d7217" } diff --git a/reflex/.templates/web/generate-shell.mjs b/reflex/.templates/web/generate-shell.mjs new file mode 100644 index 00000000000..9ac976d08c4 --- /dev/null +++ b/reflex/.templates/web/generate-shell.mjs @@ -0,0 +1,35 @@ +/** + * Post-build script: generates a static SPA shell (build/client/index.html). + * + * With ssr:true, `react-router build` does not emit index.html because all + * HTML is rendered at request time. The production server (ssr-serve.js) + * serves this pre-built shell to regular users for instant load with zero + * SSR overhead; only bots go through the SSR path. + * + * The X-Reflex-Shell-Gen header tells the root loader to short-circuit and + * return { state: null } without contacting the Python backend. + */ +import { createRequestHandler } from "react-router"; +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolve paths relative to this file, not process.cwd(). +const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); + +const build = await import(join(__dirname, "build", "server", "index.js")); +const handler = createRequestHandler(build, "production"); + +const request = new Request("http://localhost/", { + headers: { + "User-Agent": "Mozilla/5.0 Chrome/120 (Shell Generator)", + "X-Reflex-Shell-Gen": "1", + }, +}); + +const response = await handler(request); +const html = await response.text(); + +const outPath = join(__dirname, "build", "client", "index.html"); +writeFileSync(outPath, html); +console.log("Generated build/client/index.html"); diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js new file mode 100644 index 00000000000..8ddb4e8cd3c --- /dev/null +++ b/reflex/.templates/web/ssr-serve.js @@ -0,0 +1,76 @@ +/** + * Bot-aware SSR production server for Reflex apps. + * + * - Crawlers/bots receive fully server-side rendered HTML (SEO). + * - Regular users receive the static SPA shell (fast, zero SSR overhead). + * + * Used when `runtime_ssr=True` is set in the Reflex config. + */ +import { createRequestHandler } from "@react-router/express"; +import express from "express"; +import compression from "compression"; +import { isbot } from "isbot"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolve all paths relative to *this file*, not process.cwd(). +const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); + +const clientDir = join(__dirname, "build", "client"); +const serverEntry = join(__dirname, "build", "server", "index.js"); + +const buildModule = await import(serverEntry); + +const app = express(); +app.disable("x-powered-by"); +app.use(compression()); + +// Static assets with content-hash filenames — cache immutably. +app.use( + "/assets", + express.static(join(clientDir, "assets"), { immutable: true, maxAge: "1y" }) +); + +// Other static files (favicon, sitemap, etc.) — short cache. +app.use(express.static(clientDir, { maxAge: "1h" })); + +// SSR request handler (React Router). +const ssrHandler = createRequestHandler({ build: buildModule }); + +// Check if the static SPA shell exists (generated by generate-shell.mjs). +const shellPath = join(clientDir, "index.html"); +const hasShell = existsSync(shellPath); +if (!hasShell) { + console.warn( + "[ssr-serve] build/client/index.html not found — all requests will use SSR." + ); +} + +app.all("*", (req, res, next) => { + const ua = req.headers["user-agent"] || ""; + + // Bots always get full server-side rendered HTML with state data. + // Also used as fallback when the static shell is unavailable. + if (isbot(ua) || !hasShell) { + return ssrHandler(req, res, next); + } + + // For regular users: only serve the SPA shell for initial document requests + // (browser navigating to a URL). React Router's .data requests (used for + // client-side navigations) and other non-document fetches must go through + // the SSR handler so the root loader can run and return JSON state data. + const accept = req.headers["accept"] || ""; + if (accept.includes("text/html") && !req.url.endsWith(".data")) { + return res.sendFile(shellPath); + } + + // .data requests, API calls, etc. → SSR handler (runs loaders, returns JSON). + return ssrHandler(req, res, next); +}); + +const port = parseInt(process.env.PORT || "3000", 10); +app.listen(port, () => { + // Message format matches Reflex's PROD_FRONTEND_LISTENING_REGEX. + console.log(`[ssr-serve] http://localhost:${port}`); +}); diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..b5ced0bd943 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -842,6 +842,7 @@ export const useEventLoop = ( dispatch, initial_events = () => [], client_storage = {}, + ssrHydrated = false, ) => { const socket = useRef(null); const location = useLocation(); @@ -948,13 +949,27 @@ export const useEventLoop = ( const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { if (!sentHydrate.current) { - queueEvents( - initial_events(), - socket, - true, - navigate, - () => params.current, - ); + if (ssrHydrated) { + // SSR state was applied via StateProvider's initial reducer values. + // Just send hydrate to establish WebSocket session, skip on_load_internal + // since data is already loaded from the server-side render. + queueEvents( + [ReflexEvent(state_name + ".hydrate")], + socket, + true, + navigate, + () => params.current, + ); + } else { + // No SSR state — fall back to normal WebSocket hydration. + queueEvents( + initial_events(), + socket, + true, + navigate, + () => params.current, + ); + } sentHydrate.current = true; } }, []); diff --git a/reflex/app.py b/reflex/app.py index 6245a9f0d1d..80e6bbbfef6 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -12,6 +12,7 @@ import io import json import operator +import re import sys import time import traceback @@ -86,6 +87,7 @@ from reflex.istate.proxy import StateProxy from reflex.page import DECORATED_PAGES from reflex.route import ( + extract_route_params, get_route_args, replace_brackets_with_keywords, verify_route_validity, @@ -684,6 +686,12 @@ def _add_default_endpoints(self): health, methods=["GET"], ) + if get_config().runtime_ssr: + self._api.add_route( + str(constants.Endpoint.SSR_DATA), + ssr_data(self), + methods=["POST"], + ) def _add_optional_endpoints(self): """Add optional api endpoints (_upload).""" @@ -1456,8 +1464,9 @@ def _submit_work_without_advancing( progress.advance(task) # Compile the contexts. + runtime_ssr = get_config().runtime_ssr compile_results.append( - compiler.compile_contexts(self._state, self.theme), + compiler.compile_contexts(self._state, self.theme, runtime_ssr=runtime_ssr), ) if self.theme is not None: # Fix #2992 by removing the top-level appearance prop @@ -1466,7 +1475,7 @@ def _submit_work_without_advancing( # Compile the app root. compile_results.append( - compiler.compile_app(app_root), + compiler.compile_app(app_root, runtime_ssr=runtime_ssr), ) progress.advance(task) @@ -1479,10 +1488,15 @@ def _submit_work_without_advancing( with console.timing("Install Frontend Packages"): self._get_frontend_packages(all_imports) - # Setup the react-router.config.js + # Setup the react-router.config.js and package.json. frontend_skeleton.update_react_router_config( prerender_routes=prerender_routes, ) + frontend_skeleton.initialize_package_json() + + # Copy SSR scripts when runtime SSR is enabled. + if runtime_ssr: + frontend_skeleton.copy_ssr_scripts() if is_prod_mode(): # Empty the .web pages directory. @@ -1801,6 +1815,21 @@ async def process( if (path := router_data.get(constants.RouteVar.PATH)) else "404" ).removeprefix("/") + # Server-side extraction of dynamic route params as a fallback. + # When the SPA shell is served for a direct page visit, the client + # may not have route params yet (React Router lazy route discovery + # hasn't completed), so extract them from the pathname to ensure + # on_load handlers receive correct params like slug, id, etc. + if path: + server_params = extract_route_params( + path, router_data[constants.RouteVar.PATH] + ) + if server_params: + query = router_data.get(constants.RouteVar.QUERY, {}) + for key, value in server_params.items(): + if not query.get(key): + query[key] = value + router_data[constants.RouteVar.QUERY] = query # re-assign only when the value is different if state.router_data != router_data: # assignment will recurse into substates and force recalculation of @@ -1886,6 +1915,135 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) +async def _run_ssr_on_load(state: BaseState, load_event: Any, path: str) -> None: + """Execute a single on_load handler on the ephemeral SSR state. + + Args: + state: The ephemeral root state instance. + load_event: The on_load event handler to execute. + path: The URL path (for error logging). + """ + try: + handler_fn = ( + load_event.handler if hasattr(load_event, "handler") else load_event + ) + # Get the full handler name. + if hasattr(handler_fn, "fn") and hasattr(handler_fn, "state_full_name"): + handler_name = f"{handler_fn.state_full_name}.{handler_fn.fn.__name__}" + else: + handler_name = str(handler_fn) + + # Find the target substate and event handler. + target_state, event_handler = state._get_event_handler(handler_name) + + # Execute the handler on the target substate. + result = event_handler.fn(target_state) + if asyncio.iscoroutine(result): + result = await result + # For generators (event chains), consume them but ignore returned events. + if inspect.isgenerator(result): + for _ in result: + pass + if inspect.isasyncgen(result): + async for _ in result: + pass + except Exception: + console.warn(f"SSR on_load handler failed for {path}: {traceback.format_exc()}") + + +def ssr_data(app: App): + """SSR data loader endpoint. + + Creates an ephemeral state, sets route params, runs on_load handlers, + and returns the serialized state for server-side rendering. + + Args: + app: The app to get SSR data for. + + Returns: + The SSR data handler function. + """ + + async def ssr_data_handler(request: Request) -> Response: + """Handle an SSR data request. + + Args: + request: The Starlette request object. + + Returns: + Response with the serialized state as JSON. + """ + body = await request.json() + + path = body.get("path", "/") + headers = body.get("headers", {}) + + if not app._state: + return Response( + content='{"state": null}', + media_type="application/json", + ) + + # Create an ephemeral state instance (no persistent session). + # Use State (root) rather than app._state which may be a subclass + # with inherited vars that can't be set without a parent. + state = State(_reflex_internal_init=True) + + # Resolve the route pattern from the concrete path. + resolved_route = app.router(path) or "404" + + # Extract route params from the path by matching against the route pattern. + # e.g. route="blog/[slug]", path="/blog/hello-world" => {"slug": "hello-world"} + params = extract_route_params(path, resolved_route) + + # Build router_data dict (same structure as process() uses). + router_data = { + constants.RouteVar.PATH: "/" + resolved_route.removeprefix("/"), + constants.RouteVar.ORIGIN: path, + constants.RouteVar.QUERY: { + **params, + }, + constants.RouteVar.CLIENT_TOKEN: "__ssr__", + constants.RouteVar.SESSION_ID: "__ssr__", + constants.RouteVar.HEADERS: { + "origin": headers.get( + "origin", headers.get("host", "http://localhost") + ), + **headers, + }, + constants.RouteVar.CLIENT_IP: ( + request.client.host if request.client else "0.0.0.0" + ), + } + + # Set router data on the state — this triggers DynamicRouteVar recomputation. + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + + # Get on_load event handlers for this route. + load_events = app.get_load_events(path) + + # Execute each on_load handler directly on the ephemeral state. + for load_event in load_events: + await _run_ssr_on_load(state, load_event, path) + + # Serialize the full state tree for the frontend. + full_state = state.dict() + + # Use Reflex's json serializer to handle custom types (RouterData, etc.) + json_str = format.json_dumps({"state": full_state}) + + return Response( + content=json_str, + media_type="application/json", + headers={ + "Cache-Control": "no-cache", + }, + ) + + return ssr_data_handler + + def upload(app: App): """Upload a file. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 0c8ee62c1a1..9aec8ac8934 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -73,11 +73,12 @@ def _normalize_library_name(lib: str) -> str: return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") -def _compile_app(app_root: Component) -> str: +def _compile_app(app_root: Component, runtime_ssr: bool = False) -> str: """Compile the app template component. Args: app_root: The app root to compile. + runtime_ssr: Whether runtime SSR is enabled. Returns: The compiled app. @@ -100,6 +101,7 @@ def _compile_app(app_root: Component) -> str: window_libraries=window_libraries_deduped, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), + runtime_ssr=runtime_ssr, ) @@ -115,12 +117,17 @@ def _compile_theme(theme: str) -> str: return templates.theme_template(theme=theme) -def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> str: +def _compile_contexts( + state: type[BaseState] | None, + theme: Component | None, + runtime_ssr: bool = False, +) -> str: """Compile the initial state and contexts. Args: state: The app state. theme: The top-level app theme. + runtime_ssr: Whether runtime SSR is enabled. Returns: The compiled context file. @@ -136,11 +143,13 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), + runtime_ssr=runtime_ssr, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), + runtime_ssr=runtime_ssr, ) ) @@ -491,11 +500,12 @@ def compile_document_root( return output_path, code -def compile_app(app_root: Component) -> tuple[str, str]: +def compile_app(app_root: Component, runtime_ssr: bool = False) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + runtime_ssr: Whether runtime SSR is enabled. Returns: The path and code of the compiled app wrapper. @@ -506,7 +516,7 @@ def compile_app(app_root: Component) -> tuple[str, str]: ) # Compile the document root. - code = _compile_app(app_root) + code = _compile_app(app_root, runtime_ssr=runtime_ssr) return output_path, code @@ -532,12 +542,14 @@ def compile_theme(style: ComponentStyle) -> tuple[str, str]: def compile_contexts( state: type[BaseState] | None, theme: Component | None, + runtime_ssr: bool = False, ) -> tuple[str, str]: """Compile the initial state / context. Args: state: The app state. theme: The top-level app theme. + runtime_ssr: Whether runtime SSR is enabled. Returns: The path and code of the compiled context. @@ -545,7 +557,7 @@ def compile_contexts( # Get the path for the output file. output_path = utils.get_context_path() - return output_path, _compile_contexts(state, theme) + return output_path, _compile_contexts(state, theme, runtime_ssr=runtime_ssr) def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: @@ -899,7 +911,11 @@ def compile_unevaluated_page( component = compile_unevaluated_page( route, cls.UNCOMPILED_PAGES[route], style, theme ) - return route, component, compile_page(route, component) + return ( + route, + component, + compile_page(route, component), + ) @classmethod def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]: diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 2abcb6dd533..db0957380ba 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -170,6 +170,7 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], + runtime_ssr: bool = False, ): """Template for the App root. @@ -180,6 +181,7 @@ def app_root_template( window_libraries: The list of window libraries. render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. + runtime_ssr: Whether runtime SSR is enabled. Returns: Rendered App root component as string. @@ -198,17 +200,67 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) + if runtime_ssr: + ssr_imports = ( + 'import { Outlet, useLoaderData } from "react-router";\n' + 'import { getBackendURL } from "$/utils/state";\n' + 'import env from "$/env.json";' + ) + ssr_loader = """ +export async function loader({ request }) { + // Short-circuit during static shell generation (no backend available). + if (request.headers.get("x-reflex-shell-gen") === "1") { + return { state: null }; + } + // Fetch state data from the Python backend. This loader runs in two cases: + // (a) Full SSR render for bots — ssr-serve.js routes bot requests here. + // (b) .data requests for client-side navigation — React Router calls the + // loader to fetch route data as JSON for the next page. + // Both cases need real state data from the backend. + const backendUrl = getBackendURL(env.SSR_DATA); + try { + const res = await fetch(backendUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(request.headers.get("cookie") ? { "Cookie": request.headers.get("cookie") } : {}), + }, + body: JSON.stringify({ + path: new URL(request.url).pathname, + headers: Object.fromEntries(request.headers), + }), + }); + if (res.ok) { + return res.json(); + } + } catch (e) { + console.error("SSR data fetch failed:", e); + } + return { state: null }; +} +""" + ssr_layout_head = ( + " const loaderData = useLoaderData();\n" + " const ssrState = loaderData?.state || null;\n" + ) + ssr_state_provider_props = "{ssrState}" + else: + ssr_imports = 'import { Outlet } from "react-router";' + ssr_loader = "" + ssr_layout_head = "" + ssr_state_provider_props = "{{}}" + return f""" {imports_str} {dynamic_imports_str} import {{ EventLoopProvider, StateProvider, defaultColorMode }} from "$/utils/context"; import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; -import {{ Outlet }} from 'react-router'; +{ssr_imports} {import_window_libraries} {custom_code_str} - +{ssr_loader} function AppWrap({{children}}) {{ {_render_hooks(hooks)} return ({_RenderUtils.render(render)}) @@ -216,6 +268,7 @@ def app_root_template( export function Layout({{children}}) {{ +{ssr_layout_head} useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components let windowImports = {{ @@ -226,7 +279,7 @@ def app_root_template( return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, - jsx(StateProvider, {{}}, + jsx(StateProvider, {ssr_state_provider_props}, jsx(EventLoopProvider, {{}}, jsx(AppWrap, {{}}, children) ) @@ -261,6 +314,7 @@ def context_template( initial_state: dict[str, Any] | None = None, state_name: str | None = None, client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, + runtime_ssr: bool = False, ): """Template for the context file. @@ -270,6 +324,7 @@ def context_template( client_storage: The client storage for the context. is_dev_mode: Whether the app is in development mode. default_color_mode: The default color mode for the context. + runtime_ssr: Whether runtime SSR is enabled. Returns: Rendered context file content as string. @@ -327,10 +382,16 @@ def context_template( """ ) - state_reducer_str = "\n".join( - rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])' - for state_name in initial_state - ) + if runtime_ssr: + state_reducer_str = "\n".join( + rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, ssrState !== null && ssrState["{state_name}"] != null ? ssrState["{state_name}"] : initialState["{state_name}"])' + for state_name in initial_state + ) + else: + state_reducer_str = "\n".join( + rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])' + for state_name in initial_state + ) create_state_contexts_str = "\n".join( rf"createElement(StateContexts.{format_state_name(state_name)},{{value: {format_state_name(state_name)}}}," @@ -353,8 +414,15 @@ def context_template( export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const StateContexts = {{{state_contexts_str}}}; -export const EventLoopContext = createContext(null); -export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} +export const EventLoopContext = createContext(null);{ + ''' +export const SSRContext = createContext(false);''' + if runtime_ssr + else "" + } +export const clientStorage = { + "{}" if client_storage is None else json.dumps(client_storage) + } {state_str} @@ -389,11 +457,21 @@ def context_template( }} export function EventLoopProvider({{ children }}) {{ - const dispatch = useContext(DispatchContext) + const dispatch = useContext(DispatchContext){ + ''' + const ssrHydrated = useContext(SSRContext)''' + if runtime_ssr + else "" + } const [addEvents, connectErrors] = useEventLoop( dispatch, initialEvents, - clientStorage, + clientStorage,{ + ''' + ssrHydrated,''' + if runtime_ssr + else "" + } ) return createElement( EventLoopContext.Provider, @@ -402,7 +480,9 @@ def context_template( ); }} -export function StateProvider({{ children }}) {{ +export function StateProvider({{ children{ + ", ssrState = null" if runtime_ssr else "" + } }}) {{ {state_reducer_str} const dispatchers = useMemo(() => {{ return {{ @@ -411,9 +491,11 @@ def context_template( }}, []) return ( + {"createElement(SSRContext.Provider, {value: !!ssrState}," if runtime_ssr else ""} {create_state_contexts_str} createElement(DispatchContext, {{value: dispatchers}}, children) {")" * len(initial_state)} + {")" if runtime_ssr else ""} ) }}""" @@ -454,12 +536,15 @@ def page_template( dynamic_imports_str = "\n".join(dynamic_imports) hooks_str = _render_hooks(hooks) + return f"""{imports_str} {dynamic_imports_str} {custom_code_str} + + export default function Component() {{ {hooks_str} diff --git a/reflex/config.py b/reflex/config.py index 6977d745631..cfec072ca5c 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -260,6 +260,9 @@ class BaseConfig: # The transport method for client-server communication. transport: Literal["websocket", "polling"] = "websocket" + # Enable runtime server-side rendering for search engine crawlers on dynamic routes. + runtime_ssr: bool = False + # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/reflex/constants/base.py b/reflex/constants/base.py index c8a9a1dfde5..bbc39afcd6e 100644 --- a/reflex/constants/base.py +++ b/reflex/constants/base.py @@ -169,9 +169,8 @@ class ReactRouter(Javascript): # Regex to check for message displayed when frontend comes up DEV_FRONTEND_LISTENING_REGEX = r"Local:[\s]+" - # Regex to pattern the route path in the config file - # INFO Accepting connections at http://localhost:3000 - PROD_FRONTEND_LISTENING_REGEX = r"Accepting connections at[\s]+" + # Matches output from sirv ("Accepting connections at") or ssr-serve ("[ssr-serve]"). + PROD_FRONTEND_LISTENING_REGEX = r"(?:Accepting connections at|\[ssr-serve\])[\s]+" FRONTEND_LISTENING_REGEX = ( rf"(?:{DEV_FRONTEND_LISTENING_REGEX}|{PROD_FRONTEND_LISTENING_REGEX})(.*)" diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 6a0f71ec161..00f9fb2c8d1 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -13,6 +13,7 @@ class Endpoint(Enum): AUTH_CODESPACE = "auth-codespace" HEALTH = "_health" ALL_ROUTES = "_all_routes" + SSR_DATA = "_ssr_data" def __str__(self) -> str: """Get the string representation of the endpoint. diff --git a/reflex/constants/installer.py b/reflex/constants/installer.py index 12edb911397..15f3989b4ef 100644 --- a/reflex/constants/installer.py +++ b/reflex/constants/installer.py @@ -106,7 +106,8 @@ class Commands(SimpleNamespace): DEV = "react-router dev --host" EXPORT = "react-router build" - PROD = "sirv ./build/client --single 404.html --host" + PROD_STATIC = "sirv ./build/client --single 404.html --host" + PROD_SSR = "node ssr-serve.js" PATH = "package.json" @@ -127,6 +128,10 @@ def DEPENDENCIES(cls) -> dict[str, str]: "react-router": cls._react_router_version, "react-router-dom": cls._react_router_version, "@react-router/node": cls._react_router_version, + "@react-router/serve": cls._react_router_version, + "@react-router/express": cls._react_router_version, + "express": "4.21.2", + "compression": "1.8.0", "sirv-cli": "3.0.1", "react": cls._react_version, "react-helmet": "6.1.0", diff --git a/reflex/route.py b/reflex/route.py index 761ec8f974d..5fdc5ccdd19 100644 --- a/reflex/route.py +++ b/reflex/route.py @@ -96,6 +96,39 @@ def _add_route_arg(arg_name: str, type_: str): return args +def extract_route_params(path: str, route: str) -> dict[str, str]: + """Extract dynamic route parameter values from a concrete path. + + Given a concrete path (e.g. "/blog/hello-world") and a route pattern + (e.g. "blog/[slug]"), extract the parameter values by positional matching. + + Args: + path: The concrete URL path (e.g. "/blog/hello-world"). + route: The route pattern with brackets (e.g. "blog/[slug]"). + + Returns: + Dict mapping parameter names to their values, + e.g. {"slug": "hello-world"}. + """ + route_args = get_route_args(route) + if not route_args: + return {} + + params: dict[str, str] = {} + route_parts = route.strip("/").split("/") + path_parts = path.strip("/").split("/") + for i, route_part in enumerate(route_parts): + if i < len(path_parts): + arg_match = re.match( + r"^\[{1,2}(?:\.\.\.)?([a-zA-Z_]\w*)\]{1,2}$", route_part + ) + if arg_match: + param_name = arg_match.group(1) + if param_name in route_args: + params[param_name] = path_parts[i] + return params + + def replace_brackets_with_keywords(input_string: str) -> str: """Replace brackets and everything inside it in a string with a keyword. diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 7b7408f8b3b..45bff5e7865 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -141,13 +141,34 @@ def zip_app( } if frontend: - _zip( - component_name=constants.ComponentName.FRONTEND, - target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), - root_directory=prerequisites.get_web_dir() / constants.Dirs.STATIC, - files_to_exclude=files_to_exclude, - exclude_venv_directories=False, - ) + web_dir = prerequisites.get_web_dir() + if get_config().runtime_ssr: + # SSR mode: zip build/ (client + server) + ssr-serve.js + package.json. + # Use build/ as root and include the extra files via globs from web_dir. + _zip( + component_name=constants.ComponentName.FRONTEND, + target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), + root_directory=web_dir, + files_to_exclude=files_to_exclude, + exclude_venv_directories=False, + directory_names_to_exclude={ + "node_modules", + "app", + "utils", + "styles", + "components", + "backend", + "public", + }, + ) + else: + _zip( + component_name=constants.ComponentName.FRONTEND, + target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), + root_directory=web_dir / constants.Dirs.STATIC, + files_to_exclude=files_to_exclude, + exclude_venv_directories=False, + ) if backend: _zip( @@ -187,6 +208,38 @@ def _duplicate_index_html_to_parent_directory(directory: Path): _duplicate_index_html_to_parent_directory(child) +def _generate_ssr_shell(wdir: Path): + """Generate a static SPA shell (build/client/index.html) after SSR build. + + Runs the generate-shell.mjs script which renders the app with a normal + user-agent (so the loader returns empty state) and writes the resulting + HTML to build/client/index.html. The production server (ssr-serve.js) + serves this file to non-bot users for zero SSR overhead. + + Args: + wdir: The web directory (.web/). + """ + shell_script = wdir / "generate-shell.mjs" + if not shell_script.exists(): + console.warn("generate-shell.mjs not found, skipping SPA shell generation.") + return + + console.info("Generating SPA shell for non-bot users...") + node_path = str(path_ops.get_node_path() or "node") + shell_process = processes.new_process( + [node_path, "generate-shell.mjs"], + cwd=wdir, + shell=constants.IS_WINDOWS, + ) + shell_process.wait() + if shell_process.returncode != 0: + console.warn( + "SPA shell generation failed. Non-bot users will fall back to SSR." + ) + else: + console.info("SPA shell generated successfully.") + + def build(): """Build the app for deployment. @@ -226,6 +279,11 @@ def build(): "Failed to build the frontend. Please run with --loglevel debug for more information.", ) raise SystemExit(1) + + # When runtime SSR is enabled, generate a static SPA shell for non-bot users. + if get_config().runtime_ssr: + _generate_ssr_shell(wdir) + _duplicate_index_html_to_parent_directory(wdir / constants.Dirs.STATIC) spa_fallback = wdir / constants.Dirs.STATIC / constants.ReactRouter.SPA_FALLBACK diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 96ced280fe2..c4f052e137d 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -147,6 +147,21 @@ def update_react_router_config(prerender_routes: bool = False): react_router_config_file_path.write_text(new_react_router_config) +def copy_ssr_scripts(): + """Copy SSR-related scripts from the web template to the .web directory. + + Copies ssr-serve.js (production server) and generate-shell.mjs + (post-build static shell generator) when runtime_ssr is enabled. + """ + import shutil + + web_dir = get_web_dir() + for filename in ("ssr-serve.js", "generate-shell.mjs"): + src = constants.Templates.Dirs.WEB_TEMPLATE / filename + if src.exists(): + shutil.copy2(str(src), str(web_dir / filename)) + + def _update_react_router_config(config: Config, prerender_routes: bool = False): basename = "/" + (config.frontend_path or "").strip("/") if not basename.endswith("/"): @@ -157,7 +172,7 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): "future": { "unstable_optimizeDeps": True, }, - "ssr": False, + "ssr": config.runtime_ssr, } if prerender_routes: @@ -168,11 +183,17 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): def _compile_package_json(): + config = get_config() + prod_command = ( + constants.PackageJson.Commands.PROD_SSR + if config.runtime_ssr + else constants.PackageJson.Commands.PROD_STATIC + ) return templates.package_json_template( scripts={ "dev": constants.PackageJson.Commands.DEV, "export": constants.PackageJson.Commands.EXPORT, - "prod": constants.PackageJson.Commands.PROD, + "prod": prod_command, }, dependencies=constants.PackageJson.DEPENDENCIES, dev_dependencies=constants.PackageJson.DEV_DEPENDENCIES, diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 131904e964c..f05ebeb5e1b 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -52,6 +52,22 @@ True, 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": false, "prerender": true, "build": "build"};', ), + ( + Config( + app_name="test", + runtime_ssr=True, + ), + False, + 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true};', + ), + ( + Config( + app_name="test", + runtime_ssr=True, + ), + True, + 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true, "prerender": true, "build": "build"};', + ), ], ) def test_update_react_router_config(config, export, expected_output): diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py new file mode 100644 index 00000000000..ca3a6f821e0 --- /dev/null +++ b/tests/units/test_ssr_compile.py @@ -0,0 +1,267 @@ +"""Unit tests for SSR compile output and configuration.""" + +from __future__ import annotations + +import json + +from pytest_mock import MockerFixture + +import reflex as rx +from reflex.utils.frontend_skeleton import ( + _compile_package_json, + _update_react_router_config, +) + + +class TestPackageJsonProdCommand: + """Tests for the package.json prod command based on runtime_ssr config.""" + + def test_prod_command_ssr(self, mocker: MockerFixture): + """With runtime_ssr=True, prod command is 'node ssr-serve.js'.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + + result = _compile_package_json() + pkg = json.loads(result) + + assert pkg["scripts"]["prod"] == "node ssr-serve.js" + + def test_prod_command_static(self, mocker: MockerFixture): + """With runtime_ssr=False, prod command is sirv static server.""" + conf = rx.Config(app_name="test", runtime_ssr=False) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + + result = _compile_package_json() + pkg = json.loads(result) + + assert pkg["scripts"]["prod"].startswith("sirv") + assert "node ssr-serve.js" not in pkg["scripts"]["prod"] + + def test_dev_and_export_commands_unchanged(self, mocker: MockerFixture): + """Dev and export commands are the same regardless of runtime_ssr.""" + results = {} + for ssr in (True, False): + conf = rx.Config(app_name="test", runtime_ssr=ssr) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + results[ssr] = json.loads(_compile_package_json()) + + assert results[True]["scripts"]["dev"] == results[False]["scripts"]["dev"] + assert results[True]["scripts"]["export"] == results[False]["scripts"]["export"] + + +class TestReactRouterConfig: + """Tests for react-router.config.js based on runtime_ssr config.""" + + def test_ssr_true_in_config(self): + """With runtime_ssr=True, config has ssr: true.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is True + + def test_ssr_false_in_config(self): + """With runtime_ssr=False, config has ssr: false.""" + conf = rx.Config(app_name="test", runtime_ssr=False) + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is False + + def test_ssr_with_prerender(self): + """runtime_ssr and prerender can coexist.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + result = _update_react_router_config(conf, prerender_routes=True) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is True + assert parsed["prerender"] is True + + def test_default_ssr_is_false(self): + """Default config has ssr: false.""" + conf = rx.Config(app_name="test") + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is False + + +class TestTemplateOutput: + """Tests for the generated template content based on runtime_ssr.""" + + @staticmethod + def _render_root(runtime_ssr: bool) -> str: + """Render root template with minimal valid params. + + Args: + runtime_ssr: Whether runtime SSR is enabled. + + Returns: + Rendered template string. + """ + from reflex.compiler.templates import app_root_template + + return app_root_template( + imports=[], + custom_codes=[], + hooks={}, + window_libraries=[], + render={"contents": "children"}, + dynamic_imports=set(), + runtime_ssr=runtime_ssr, + ) + + def test_root_template_has_loader_when_ssr_true(self): + """With runtime_ssr=True, root.jsx template contains the SSR loader.""" + result = self._render_root(runtime_ssr=True) + + assert "export async function loader" in result + assert "useLoaderData" in result + assert "getBackendURL" in result + assert "ssrState" in result + assert "SSR_DATA" in result + + def test_root_template_loader_checks_shell_gen_header(self): + """The SSR loader short-circuits for shell generation requests.""" + result = self._render_root(runtime_ssr=True) + + assert "x-reflex-shell-gen" in result + assert "state: null" in result + # isbot check should NOT be in the loader — bot routing is in ssr-serve.js. + assert "isbot" not in result + + def test_root_template_no_loader_when_ssr_false(self): + """With runtime_ssr=False, root.jsx template has no SSR loader.""" + result = self._render_root(runtime_ssr=False) + + assert "export async function loader" not in result + assert "useLoaderData" not in result + assert "getBackendURL" not in result + assert "ssrState" not in result + + def test_context_template_has_ssr_context_when_ssr_true(self): + """With runtime_ssr=True, context.js has SSRContext and ssrHydrated.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"test_state": {"field_rx_state_": "value"}}, + state_name="test_state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=True, + ) + + assert "SSRContext" in result + assert "ssrHydrated" in result + assert "ssrState = null" in result + assert "SSRContext.Provider" in result + + def test_context_template_no_ssr_context_when_ssr_false(self): + """With runtime_ssr=False, context.js has no SSR-related code.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"test_state": {"field_rx_state_": "value"}}, + state_name="test_state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=False, + ) + + assert "SSRContext" not in result + assert "ssrHydrated" not in result + assert "ssrState" not in result + + def test_context_template_ssr_reducer_uses_ssr_state(self): + """With runtime_ssr=True, useReducer initializers check ssrState.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"my_app.state": {"count_rx_state_": 0}}, + state_name="my_app.state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=True, + ) + + # The SSR-aware reducer initialization pattern. + assert 'ssrState !== null && ssrState["my_app.state"]' in result + + def test_context_template_static_reducer_no_ssr_state(self): + """With runtime_ssr=False, useReducer uses initialState directly.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"my_app.state": {"count_rx_state_": 0}}, + state_name="my_app.state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=False, + ) + + assert 'useReducer(applyDelta, initialState["my_app.state"]' in result + assert "ssrState" not in result + + +class TestExtractRouteParams: + """Tests for the extract_route_params utility function.""" + + def test_simple_dynamic_route(self): + """Single dynamic segment is extracted correctly.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/hello-world", "blog/[slug]") + assert result == {"slug": "hello-world"} + + def test_multiple_dynamic_segments(self): + """Multiple dynamic segments are extracted correctly.""" + from reflex.route import extract_route_params + + result = extract_route_params("/users/42/posts/99", "users/[id]/posts/[pid]") + assert result == {"id": "42", "pid": "99"} + + def test_no_dynamic_segments(self): + """Static route returns empty dict.""" + from reflex.route import extract_route_params + + result = extract_route_params("/about", "about") + assert result == {} + + def test_root_path(self): + """Root path with no segments returns empty dict.""" + from reflex.route import extract_route_params + + result = extract_route_params("/", "/") + assert result == {} + + def test_leading_slash_handling(self): + """Leading slashes on both path and route are handled.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/my-post", "/blog/[slug]") + assert result == {"slug": "my-post"} + + def test_optional_segment(self): + """Optional dynamic segment ([[param]]) is extracted.""" + from reflex.route import extract_route_params + + result = extract_route_params("/docs/intro", "docs/[[section]]") + assert result == {"section": "intro"} + + def test_no_match_shorter_path(self): + """When path has fewer segments than route, missing params are skipped.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog", "blog/[slug]") + assert result == {} + + def test_preserves_special_characters_in_value(self): + """Values with hyphens and other URL-safe chars are preserved.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/my-great-post-2024", "blog/[slug]") + assert result == {"slug": "my-great-post-2024"} diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py new file mode 100644 index 00000000000..c29e36dbebc --- /dev/null +++ b/tests/units/test_ssr_data.py @@ -0,0 +1,279 @@ +"""Unit tests for the /_ssr_data endpoint handler.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from starlette.responses import Response + +import reflex as rx +from reflex.app import App, ssr_data + + +def _make_request(path: str = "/", headers: dict | None = None) -> Mock: + """Create a mock Starlette Request with the given path and headers. + + Args: + path: The URL path to include in the request body. + headers: Optional headers dict to include in the request body. + + Returns: + A mock Request object. + """ + body = {"path": path, "headers": headers or {}} + request = Mock() + request.json = AsyncMock(return_value=body) + request.client = Mock() + request.client.host = "127.0.0.1" + return request + + +def _parse_response(response: Response) -> dict[str, Any]: + """Parse a Starlette Response body as JSON. + + Args: + response: The response to parse. + + Returns: + Parsed JSON as a dict. + """ + assert isinstance(response.body, bytes) + return json.loads(response.body) + + +@pytest.mark.asyncio +async def test_ssr_data_no_state(): + """When the app has no state, the endpoint returns null state.""" + app = App(enable_state=False) + app.add_page(lambda: rx.text("hello"), route="/") + handler = ssr_data(app) + + response = await handler(_make_request("/")) + + assert response.status_code == 200 + data = _parse_response(response) + assert data["state"] is None + + +@pytest.mark.asyncio +async def test_ssr_data_basic_state(): + """The endpoint returns serialized state for a basic stateful app.""" + + class BasicState(rx.State): + title: str = "default" + + app = App() + app._state = BasicState + app.add_page(lambda: rx.text(BasicState.title), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/")) + + assert response.status_code == 200 + data = _parse_response(response) + assert data["state"] is not None + + # The root State should be present. + root_name = rx.State.get_full_name() + assert root_name in data["state"] + + # The user's substate should also be present with the default value. + substate_name = BasicState.get_full_name() + assert substate_name in data["state"] + assert data["state"][substate_name]["title_rx_state_"] == "default" + + +@pytest.mark.asyncio +async def test_ssr_data_dynamic_route_params(): + """Route params are extracted from the URL path and set on the state.""" + + class PostState(rx.State): + pass + + app = App() + app._state = PostState + app.add_page(lambda: rx.text("post"), route="/blog/[slug]") + + handler = ssr_data(app) + response = await handler(_make_request("/blog/hello-world")) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["page"]["params"] == {"slug": "hello-world"} + assert router["page"]["raw_path"] == "/blog/hello-world" + + +@pytest.mark.asyncio +async def test_ssr_data_on_load_runs(): + """The on_load handler runs and mutates state before serialization.""" + + class LoadState(rx.State): + title: str = "" + + @rx.event + def on_load_post(self): + self.title = "loaded" + + app = App() + app._state = LoadState + app.add_page( + lambda: rx.text(LoadState.title), + route="/page", + on_load=LoadState.on_load_post, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/page")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = LoadState.get_full_name() + assert data["state"][substate_name]["title_rx_state_"] == "loaded" + + +@pytest.mark.asyncio +async def test_ssr_data_async_on_load(): + """An async on_load handler is properly awaited.""" + + class AsyncLoadState(rx.State): + message: str = "" + + @rx.event + async def load_data(self): + self.message = "async-loaded" + + app = App() + app._state = AsyncLoadState + app.add_page( + lambda: rx.text(AsyncLoadState.message), + route="/async", + on_load=AsyncLoadState.load_data, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/async")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = AsyncLoadState.get_full_name() + assert data["state"][substate_name]["message_rx_state_"] == "async-loaded" + + +@pytest.mark.asyncio +async def test_ssr_data_on_load_error_graceful(): + """If on_load raises, the endpoint returns state with defaults (no crash).""" + + class ErrorState(rx.State): + value: str = "untouched" + + @rx.event + def bad_handler(self): + msg = "boom" + raise RuntimeError(msg) + + app = App() + app._state = ErrorState + app.add_page( + lambda: rx.text(ErrorState.value), + route="/error", + on_load=ErrorState.bad_handler, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/error")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = ErrorState.get_full_name() + # State should still be returned with original defaults. + assert data["state"][substate_name]["value_rx_state_"] == "untouched" + + +@pytest.mark.asyncio +async def test_ssr_data_headers_forwarded(): + """Request headers are set on the state's router headers.""" + + class HeaderState(rx.State): + pass + + app = App() + app._state = HeaderState + app.add_page(lambda: rx.text("h"), route="/") + + handler = ssr_data(app) + response = await handler( + _make_request( + "/", headers={"user-agent": "Googlebot", "origin": "https://example.com"} + ) + ) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["headers"]["user_agent"] == "Googlebot" + + +@pytest.mark.asyncio +async def test_ssr_data_unknown_route(): + """An unknown path resolves to the 404 route.""" + + class NotFoundState(rx.State): + pass + + app = App() + app._state = NotFoundState + app.add_page(lambda: rx.text("home"), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/this/does/not/exist")) + + assert response.status_code == 200 + data = _parse_response(response) + # Should still return valid state (the 404 handler path). + assert data["state"] is not None + + +@pytest.mark.asyncio +async def test_ssr_data_cache_control_header(): + """The response includes Cache-Control: no-cache.""" + + class CacheState(rx.State): + pass + + app = App() + app._state = CacheState + app.add_page(lambda: rx.text("c"), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/")) + + assert response.headers["cache-control"] == "no-cache" + + +@pytest.mark.asyncio +async def test_ssr_data_client_ip(): + """The client IP from the request is set in the state.""" + + class IpState(rx.State): + pass + + app = App() + app._state = IpState + app.add_page(lambda: rx.text("ip"), route="/") + + handler = ssr_data(app) + request = _make_request("/") + request.client.host = "10.0.0.42" + response = await handler(request) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["session"]["client_ip"] == "10.0.0.42" From 0606e2bbbbfd4afcd56d10e1303cce632ef5eecd Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 13 Mar 2026 01:02:01 +0100 Subject: [PATCH 02/10] fix: add fixture to clean state subclasses after tests --- tests/units/test_ssr_data.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py index c29e36dbebc..1d4788b2a78 100644 --- a/tests/units/test_ssr_data.py +++ b/tests/units/test_ssr_data.py @@ -11,6 +11,33 @@ import reflex as rx from reflex.app import App, ssr_data +from reflex.state import State, all_base_state_classes + + +@pytest.fixture(autouse=True, scope="module") +def _clean_state_subclasses(): + """Snapshot and restore State subclass registrations after all tests. + + Tests in this module define rx.State subclasses inside test functions, + which permanently registers them in the global class hierarchy. Without + cleanup, these leak into later test modules (e.g. test_state.py) and + cause failures. + """ + orig_subclasses = State.class_subclasses.copy() + orig_all = all_base_state_classes.copy() + orig_dirty = State._potentially_dirty_states.copy() + orig_always_dirty = State._always_dirty_substates.copy() + orig_var_deps = State._var_dependencies.copy() + + yield + + State.class_subclasses = orig_subclasses + State._potentially_dirty_states = orig_dirty + State._always_dirty_substates = orig_always_dirty + State._var_dependencies = orig_var_deps + all_base_state_classes.clear() + all_base_state_classes.update(orig_all) + State.get_class_substate.cache_clear() def _make_request(path: str = "/", headers: dict | None = None) -> Mock: From 32bd4a7ce8497d9637d8c41f5e23fd0b13a543db Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 13 Mar 2026 01:20:59 +0100 Subject: [PATCH 03/10] make ssr deps optional --- reflex/app.py | 1 - reflex/compiler/templates.py | 2 -- reflex/constants/installer.py | 19 +++++++++++++++---- reflex/utils/frontend_skeleton.py | 5 ++++- tests/units/test_ssr_compile.py | 17 +++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index 439dfb5444f..a1cdb532633 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -12,7 +12,6 @@ import io import json import operator -import re import sys import time import traceback diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index fc4cac013aa..5320314eacb 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -543,8 +543,6 @@ def page_template( {custom_code_str} - - export default function Component() {{ {hooks_str} diff --git a/reflex/constants/installer.py b/reflex/constants/installer.py index 6501a7383ca..84801fede16 100644 --- a/reflex/constants/installer.py +++ b/reflex/constants/installer.py @@ -141,10 +141,6 @@ def DEPENDENCIES(cls) -> dict[str, str]: "react-router": cls._react_router_version, "react-router-dom": cls._react_router_version, "@react-router/node": cls._react_router_version, - "@react-router/serve": cls._react_router_version, - "@react-router/express": cls._react_router_version, - "express": "4.21.2", - "compression": "1.8.0", "sirv-cli": "3.0.1", "react": cls._react_version, "react-helmet": "6.1.0", @@ -154,6 +150,21 @@ def DEPENDENCIES(cls) -> dict[str, str]: "universal-cookie": "7.2.2", } + @classproperty + @classmethod + def SSR_DEPENDENCIES(cls) -> dict[str, str]: + """Additional dependencies required when runtime_ssr is enabled. + + Returns: + A dictionary of SSR-specific dependencies with their versions. + """ + return { + "@react-router/serve": cls._react_router_version, + "@react-router/express": cls._react_router_version, + "express": "4.21.2", + "compression": "1.8.0", + } + DEV_DEPENDENCIES = { "@emotion/react": "11.14.0", "autoprefixer": "10.4.24", diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 23e99b5938b..afcbe00a282 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -189,13 +189,16 @@ def _compile_package_json(): if config.runtime_ssr else constants.PackageJson.Commands.get_prod_command(config.frontend_path) ) + deps = dict(constants.PackageJson.DEPENDENCIES) + if config.runtime_ssr: + deps.update(constants.PackageJson.SSR_DEPENDENCIES) return templates.package_json_template( scripts={ "dev": constants.PackageJson.Commands.DEV, "export": constants.PackageJson.Commands.EXPORT, "prod": prod_command, }, - dependencies=constants.PackageJson.DEPENDENCIES, + dependencies=deps, dev_dependencies=constants.PackageJson.DEV_DEPENDENCIES, overrides=constants.PackageJson.OVERRIDES, ) diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py index ca3a6f821e0..888dd229f0f 100644 --- a/tests/units/test_ssr_compile.py +++ b/tests/units/test_ssr_compile.py @@ -37,6 +37,23 @@ def test_prod_command_static(self, mocker: MockerFixture): assert pkg["scripts"]["prod"].startswith("sirv") assert "node ssr-serve.js" not in pkg["scripts"]["prod"] + def test_ssr_deps_only_when_enabled(self, mocker: MockerFixture): + """SSR-specific deps are only included when runtime_ssr=True.""" + ssr_only_deps = ("@react-router/express", "express", "compression") + + for ssr in (True, False): + conf = rx.Config(app_name="test", runtime_ssr=ssr) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + pkg = json.loads(_compile_package_json()) + deps = pkg["dependencies"] + for dep in ssr_only_deps: + if ssr: + assert dep in deps, f"{dep} should be present when runtime_ssr=True" + else: + assert dep not in deps, ( + f"{dep} should NOT be present when runtime_ssr=False" + ) + def test_dev_and_export_commands_unchanged(self, mocker: MockerFixture): """Dev and export commands are the same regardless of runtime_ssr.""" results = {} From 1158c3c75dbfc3edcc4f6947c678ee21890ce915 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 13 Mar 2026 20:27:25 +0100 Subject: [PATCH 04/10] add integration test --- reflex/.templates/web/ssr-serve.js | 8 +- reflex/testing.py | 116 +++++++ .../integration/tests_playwright/test_ssr.py | 314 ++++++++++++++++++ 3 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 tests/integration/tests_playwright/test_ssr.py diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js index 8ddb4e8cd3c..8d439fc7089 100644 --- a/reflex/.templates/web/ssr-serve.js +++ b/reflex/.templates/web/ssr-serve.js @@ -69,8 +69,10 @@ app.all("*", (req, res, next) => { return ssrHandler(req, res, next); }); -const port = parseInt(process.env.PORT || "3000", 10); -app.listen(port, () => { +const requestedPort = parseInt(process.env.PORT || "3000", 10); +const server = app.listen(requestedPort, () => { + // Emit the actual port (important when PORT=0 for auto-assignment). // Message format matches Reflex's PROD_FRONTEND_LISTENING_REGEX. - console.log(`[ssr-serve] http://localhost:${port}`); + const actualPort = server.address().port; + console.log(`[ssr-serve] http://localhost:${actualPort}`); }); diff --git a/reflex/testing.py b/reflex/testing.py index 4ab72602334..7dfe8f76e1e 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -1134,3 +1134,119 @@ def stop(self): self.frontend_server.shutdown() if self.frontend_thread is not None: self.frontend_thread.join() + + +class AppHarnessSSR(AppHarnessProd): + """AppHarnessSSR runs a Reflex app with runtime SSR via ssr-serve.js. + + Instead of serving static files with Python's http.server, this harness + runs the Node.js ``ssr-serve.js`` Express server that provides bot-aware + server-side rendering. Regular (non-bot) users still receive the SPA shell + for fast hydration, while crawlers get fully rendered HTML. + + Use this harness with ``runtime_ssr=True`` in ``rxconfig.py``. + """ + + def _initialize_app(self): + """Initialize the app and patch the config for runtime SSR. + + The base ``_initialize_app`` scaffolds the project (``_init``), reloads + the config from disk, then creates the app instance (which registers + endpoints). We need ``runtime_ssr=True`` in the config **before** the + app instance is created so that the ``/_ssr_data`` endpoint is + registered and the ``package.json`` includes SSR dependencies. + + Strategy: call ``super()`` which does everything, then: + 1. Patch ``rxconfig.py`` on disk so future reloads pick it up. + 2. Set the live config to ``runtime_ssr=True``. + 3. Manually register the ``/_ssr_data`` endpoint on the already-created + ASGI app (since it was skipped during initial creation). + """ + super()._initialize_app() + + # 1. Patch the on-disk rxconfig.py for future reinit / export. + rxconfig_path = self.app_path / reflex.constants.Config.FILE + content = rxconfig_path.read_text() + content = content.replace("rx.Config(", "rx.Config(\n runtime_ssr=True,") + rxconfig_path.write_text(content) + + # 2. Enable runtime_ssr on the live config singleton. + get_config().runtime_ssr = True + + # 3. Register the /_ssr_data endpoint that was skipped during init. + if self.app_instance is not None and self.app_instance._api is not None: + from reflex.app import ssr_data + + self.app_instance._api.add_route( + str(reflex.constants.Endpoint.SSR_DATA), + ssr_data(self.app_instance), + methods=["POST"], + ) + + def _start_frontend(self): + """Export the app and launch ssr-serve.js as the frontend process. + + After export, an extra ``bun install`` is run to pick up SSR + dependencies that were written to ``package.json`` by ``_compile()`` + *after* the initial package install (a sequencing nuance: in normal + usage the package.json is correct from the start, but here the test + harness flipped ``runtime_ssr`` after the first ``_init``). + """ + with chdir(self.app_path): + config = reflex.config.get_config() + print("Polling for servers...") # for pytest diagnosis # noqa: T201 + config.api_url = "http://{}:{}".format( + *self._poll_for_servers(timeout=30).getsockname(), + ) + print("Building frontend (SSR)...") # for pytest diagnosis # noqa: T201 + + get_config().loglevel = reflex.constants.LogLevel.INFO + + reflex.utils.prerequisites.assert_in_reflex_dir() + + if reflex.utils.prerequisites.needs_reinit(): + reflex.reflex._init(name=get_config().app_name) + + export( + zipping=False, + frontend=True, + backend=False, + loglevel=reflex.constants.LogLevel.INFO, + env=reflex.constants.Env.PROD, + ) + + # export() regenerated package.json with SSR deps but the initial + # bun install ran before that. Run install once more so that + # @react-router/express, express, compression are available. + web_dir = self.app_path / reflex.utils.prerequisites.get_web_dir() + print("Installing SSR dependencies...") # for pytest diagnosis # noqa: T201 + install_proc = reflex.utils.processes.new_process( + [ + *js_runtimes.get_js_package_executor(raise_on_none=True)[0], + "install", + "--legacy-peer-deps", + ], + cwd=web_dir, + ) + install_proc.communicate() + + print("Frontend starting (SSR)...") # for pytest diagnosis # noqa: T201 + + from reflex.utils.path_ops import get_node_path + + node = str(get_node_path() or "node") + self.frontend_process = reflex.utils.processes.new_process( + [node, "ssr-serve.js"], + cwd=web_dir, + env={"PORT": "0", "NO_COLOR": "1"}, + **FRONTEND_POPEN_ARGS, + ) + + def _wait_frontend(self): + """Wait for ssr-serve.js to emit its listening URL on stdout. + + Re-uses the base ``AppHarness._wait_frontend`` which parses + ``FRONTEND_LISTENING_REGEX`` — that regex already matches the + ``[ssr-serve] http://localhost:`` output format. + """ + AppHarness._wait_frontend(self) diff --git a/tests/integration/tests_playwright/test_ssr.py b/tests/integration/tests_playwright/test_ssr.py new file mode 100644 index 00000000000..e2748b884e9 --- /dev/null +++ b/tests/integration/tests_playwright/test_ssr.py @@ -0,0 +1,314 @@ +"""Integration tests for runtime SSR (server-side rendering). + +Spins up a blog app with ``runtime_ssr=True`` via ``AppHarnessSSR``, then +verifies: + - Bots receive fully rendered HTML with blog data. + - Normal users receive the SPA shell (no blog content in raw HTML). + - Playwright (a real browser) can navigate to dynamic routes and hydrate. + - The ``/_ssr_data`` backend endpoint returns serialized state. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import httpx +import pytest +from playwright.sync_api import Page, expect + +import reflex as rx +from reflex.testing import AppHarnessSSR + +# --------------------------------------------------------------------------- +# App source - a minimal blog with a dynamic /blog/[slug] route. +# --------------------------------------------------------------------------- + + +def SSRBlogApp(): + """A blog app with dynamic routes for SSR testing.""" + import reflex as rx + + POSTS = { + "hello-world": { + "title": "Hello World", + "content": "First post content for SSR testing.", + "author": "Test Author", + }, + "second-post": { + "title": "Second Post", + "content": "Another post for navigation tests.", + "author": "Test Author", + }, + } + + class BlogState(rx.State): + title: str = "" + content: str = "" + author: str = "" + not_found: bool = False + + @rx.event + def on_load_post(self): + slug = self.slug + post = POSTS.get(slug) + if post: + self.title = post["title"] + self.content = post["content"] + self.author = post["author"] + self.not_found = False + else: + self.title = "Not Found" + self.content = f"No post with slug '{slug}'" + self.author = "" + self.not_found = True + + def index() -> rx.Component: + return rx.container( + rx.heading("SSR Blog", size="8", data_testid="index-heading"), + rx.vstack( + rx.link( + "Hello World", + href="/blog/hello-world", + data_testid="link-hello-world", + ), + rx.link( + "Second Post", + href="/blog/second-post", + data_testid="link-second-post", + ), + spacing="3", + ), + ) + + @rx.page( + route="/blog/[slug]", + title="Blog Post", + on_load=BlogState.on_load_post, + ) + def blog_post() -> rx.Component: + return rx.container( + rx.link("Back", href="/", data_testid="back-link"), + rx.cond( + BlogState.not_found, + rx.text("Post Not Found", data_testid="not-found"), + rx.vstack( + rx.heading(BlogState.title, size="7", data_testid="post-title"), + rx.text(BlogState.author, data_testid="post-author"), + rx.text(BlogState.content, data_testid="post-content"), + spacing="4", + ), + ), + ) + + app = rx.App() + app.add_page(index) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +GOOGLEBOT_UA = ( + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +) + +CHROME_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +@pytest.fixture(scope="module") +def ssr_blog_app(tmp_path_factory) -> Generator[AppHarnessSSR, None, None]: + """Create and start the SSR blog app. + + Args: + tmp_path_factory: pytest fixture for creating temporary directories. + + Yields: + AppHarnessSSR: A running harness for the SSR blog app. + """ + with AppHarnessSSR.create( + root=tmp_path_factory.mktemp("ssr_blog"), + app_source=SSRBlogApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_bot_gets_ssr_html(ssr_blog_app: AppHarnessSSR): + """Bots (Googlebot) should receive fully rendered HTML with blog data. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + assert ssr_blog_app.frontend_url is not None + url = f"{ssr_blog_app.frontend_url}/blog/hello-world" + resp = httpx.get(url, headers={"User-Agent": GOOGLEBOT_UA}, follow_redirects=True) + assert resp.status_code == 200 + body = resp.text + # The HTML should contain the actual blog post data (server-rendered). + assert "Hello World" in body + assert "First post content for SSR testing." in body + assert "Test Author" in body + + +def test_normal_user_gets_spa_shell(ssr_blog_app: AppHarnessSSR): + """Normal users should receive the SPA shell without blog-specific content. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + assert ssr_blog_app.frontend_url is not None + url = f"{ssr_blog_app.frontend_url}/blog/hello-world" + resp = httpx.get( + url, + headers={ + "User-Agent": CHROME_UA, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + follow_redirects=True, + ) + assert resp.status_code == 200 + body = resp.text + # The SPA shell should NOT contain post-specific rendered content. + assert "First post content for SSR testing." not in body + # But it should be valid HTML with the app shell structure. + assert " home -> Post B to verify multi-step navigation. + + Args: + ssr_blog_app: The running SSR blog app harness. + page: A Playwright page. + """ + assert ssr_blog_app.frontend_url is not None + + # Start at the first post. + page.goto(f"{ssr_blog_app.frontend_url}/blog/hello-world") + title = page.get_by_test_id("post-title") + expect(title).to_be_visible(timeout=15000) + expect(title).to_have_text("Hello World") + + # Go back to index. + back = page.get_by_test_id("back-link") + back.click() + heading = page.get_by_test_id("index-heading") + expect(heading).to_be_visible(timeout=15000) + + # Navigate to the second post. + link = page.get_by_test_id("link-second-post") + expect(link).to_be_visible() + link.click() + + title2 = page.get_by_test_id("post-title") + expect(title2).to_be_visible(timeout=15000) + expect(title2).to_have_text("Second Post") + + content2 = page.get_by_test_id("post-content") + expect(content2).to_have_text("Another post for navigation tests.") + + +def test_ssr_data_endpoint(ssr_blog_app: AppHarnessSSR): + """The /_ssr_data backend endpoint should return serialized state. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + api_url = rx.config.get_config().api_url + assert api_url is not None + + resp = httpx.post( + f"{api_url}/{rx.constants.Endpoint.SSR_DATA.value}", + json={"path": "/blog/hello-world", "headers": {}}, + ) + assert resp.status_code == 200 + + data = resp.json() + assert "state" in data + state = data["state"] + # The state is a nested dict: top-level keys are substate paths like + # "reflex___state____state.ssrblogapp___ssrblogapp____blog_state". + # Var names are mangled with a suffix (e.g. "title" → "title_rx_state_"). + # Find the BlogState substate and verify blog data is present. + blog_state = None + for key, value in state.items(): + if "blog_state" in key and isinstance(value, dict): + blog_state = value + break + assert blog_state is not None, ( + f"BlogState substate not found in: {list(state.keys())}" + ) + # Check that the title field is set (may be mangled with a suffix). + title_values = [v for k, v in blog_state.items() if k.startswith("title")] + assert any(v == "Hello World" for v in title_values), ( + f"Expected 'Hello World' in title fields: {blog_state}" + ) + + +def test_not_found_post(ssr_blog_app: AppHarnessSSR, page: Page): + """Navigating to a non-existent slug should show the "Post Not Found" text. + + Args: + ssr_blog_app: The running SSR blog app harness. + page: A Playwright page. + """ + assert ssr_blog_app.frontend_url is not None + page.goto(f"{ssr_blog_app.frontend_url}/blog/does-not-exist") + + not_found = page.get_by_test_id("not-found") + expect(not_found).to_be_visible(timeout=15000) + expect(not_found).to_have_text("Post Not Found") From 0287832a87875dc49d14da6a6a72e33785fafd24 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Fri, 13 Mar 2026 20:32:57 +0100 Subject: [PATCH 05/10] fix typing --- reflex/app.py | 2 +- tests/integration/tests_playwright/test_ssr.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index a1cdb532633..483f3512d8d 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1991,7 +1991,7 @@ async def ssr_data_handler(request: Request) -> Response: # Create an ephemeral state instance (no persistent session). # Use State (root) rather than app._state which may be a subclass # with inherited vars that can't be set without a parent. - state = State(_reflex_internal_init=True) + state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] # Resolve the route pattern from the concrete path. resolved_route = app.router(path) or "404" diff --git a/tests/integration/tests_playwright/test_ssr.py b/tests/integration/tests_playwright/test_ssr.py index e2748b884e9..2d82d7222c5 100644 --- a/tests/integration/tests_playwright/test_ssr.py +++ b/tests/integration/tests_playwright/test_ssr.py @@ -42,14 +42,14 @@ def SSRBlogApp(): } class BlogState(rx.State): - title: str = "" - content: str = "" - author: str = "" - not_found: bool = False + title: rx.Field[str] = rx.field("") + content: rx.Field[str] = rx.field("") + author: rx.Field[str] = rx.field("") + not_found: rx.Field[bool] = rx.field(False) @rx.event def on_load_post(self): - slug = self.slug + slug: str = self.slug # pyright: ignore[reportAttributeAccessIssue] post = POSTS.get(slug) if post: self.title = post["title"] From 263d3eb07c5b7f1ac6cfd65bc17ab5a62c79b161 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 14 Mar 2026 19:51:37 +0100 Subject: [PATCH 06/10] codespell --- reflex/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/testing.py b/reflex/testing.py index 7dfe8f76e1e..fefb522cf8f 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -1245,7 +1245,7 @@ def _start_frontend(self): def _wait_frontend(self): """Wait for ssr-serve.js to emit its listening URL on stdout. - Re-uses the base ``AppHarness._wait_frontend`` which parses + Reuses the base ``AppHarness._wait_frontend`` which parses ``FRONTEND_LISTENING_REGEX`` — that regex already matches the ``[ssr-serve] http://localhost:`` output format. """ From fe877a6e87e210578a97b53c177add2dce2d44ee Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 14 Mar 2026 20:01:20 +0100 Subject: [PATCH 07/10] fix --- reflex/compiler/templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 5320314eacb..329d3ffe316 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -248,7 +248,7 @@ def app_root_template( ssr_imports = 'import { Outlet } from "react-router";' ssr_loader = "" ssr_layout_head = "" - ssr_state_provider_props = "{{}}" + ssr_state_provider_props = "{}" return f""" {imports_str} From 9369532e90d9b2c113eaa39cd93a1c0533e4fcdf Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 14 Mar 2026 21:13:43 +0100 Subject: [PATCH 08/10] fix hashes --- pyi_hashes.json | 212 ++++++++++++++++++++++++------------------------ 1 file changed, 106 insertions(+), 106 deletions(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 0b2c93927f2..ec9bbff8850 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -2,121 +2,121 @@ "reflex/__init__.pyi": "0a3ae880e256b9fd3b960e12a2cb51a7", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", - "reflex/components/base/app_wrap.pyi": "e39ea07ef30b1989376a326ffcc36296", - "reflex/components/base/body.pyi": "c9124f9d81839aca65c6a58dd1b1213f", - "reflex/components/base/document.pyi": "aa94fb557bc3999f216b6124b78a37f5", - "reflex/components/base/error_boundary.pyi": "93422a1c80b459b48ef824c735aa9552", - "reflex/components/base/fragment.pyi": "f2bf3351646f43e2a0cae6100e506a43", - "reflex/components/base/link.pyi": "9fea3e2b04f662ac84f821ddaba75049", - "reflex/components/base/meta.pyi": "7effa17dc9f151182fe83c82fb32a78b", - "reflex/components/base/script.pyi": "5b40f0ba967495bc49dd3ec0577e2b55", - "reflex/components/base/strict_mode.pyi": "724635b7543ccf1c41d83951074ead47", + "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", + "reflex/components/base/body.pyi": "e8ab029a730824bab6d4211203609e6a", + "reflex/components/base/document.pyi": "311c53c90a60587a82e760103758a3cf", + "reflex/components/base/error_boundary.pyi": "a678cceea014cb16048647257cd24ba6", + "reflex/components/base/fragment.pyi": "745f1be02c23a0b25d7c52d7423ec76a", + "reflex/components/base/link.pyi": "0bc1d26ee29d8864aed14a12991bd47d", + "reflex/components/base/meta.pyi": "129aecf65ab53f756c4d1cbe1d0b188d", + "reflex/components/base/script.pyi": "e5f506d1d0d6712cb9e597a781eb3941", + "reflex/components/base/strict_mode.pyi": "6b72e16caadf7158ab744a0ab751b010", "reflex/components/core/__init__.pyi": "007170b97e58bdf28b2aee381d91c0c7", - "reflex/components/core/auto_scroll.pyi": "7d0d4b5271ee0759b4885802959dd6f4", - "reflex/components/core/banner.pyi": "01317be6f0e1ae96238a2d1ed0f4b204", - "reflex/components/core/clipboard.pyi": "22e685d6b8442bfd5fc5796e56bc9682", - "reflex/components/core/debounce.pyi": "ee09e8f946d67838be1ace268e6729f7", - "reflex/components/core/helmet.pyi": "c8c1bdf8e2be8f467f7fad6413561e9f", - "reflex/components/core/html.pyi": "8d470113864320f28d4cbc68e85bfc47", - "reflex/components/core/sticky.pyi": "3f96767ee672644ad7a182090d914040", - "reflex/components/core/upload.pyi": "4d854de28f5138d3fbbd6ce99017f11b", - "reflex/components/core/window_events.pyi": "d4e34bc4aa4c642c661bf1fb85ab2ed2", + "reflex/components/core/auto_scroll.pyi": "18068d22aca7244a08cd0c5a897c0950", + "reflex/components/core/banner.pyi": "fd93e7a92961de8524718ad32135c37c", + "reflex/components/core/clipboard.pyi": "a844eb927d9bc2a43f5e88161b258539", + "reflex/components/core/debounce.pyi": "055da7aa890f44fb4d48bd5978f1a874", + "reflex/components/core/helmet.pyi": "43f8497c8fafe51e29dca1dd535d143a", + "reflex/components/core/html.pyi": "86eb9d4c1bb4807547b2950d9a32e9fd", + "reflex/components/core/sticky.pyi": "cb763b986a9b0654d1a3f33440dfcf60", + "reflex/components/core/upload.pyi": "6dc28804a6dddf903e31162e87c1b023", + "reflex/components/core/window_events.pyi": "af33ccec866b9540ee7fbec6dbfbd151", "reflex/components/datadisplay/__init__.pyi": "52755871369acbfd3a96b46b9a11d32e", - "reflex/components/datadisplay/code.pyi": "72f5b0315bd21fbfdacf4e675b953ac2", - "reflex/components/datadisplay/dataeditor.pyi": "bb24db26c3a86c0509fd88a8df37128d", - "reflex/components/datadisplay/shiki_code_block.pyi": "6c9fcaf8e428acf8f77096eab1723d3d", + "reflex/components/datadisplay/code.pyi": "b86769987ef4d1cbdddb461be88539fd", + "reflex/components/datadisplay/dataeditor.pyi": "f8c1e816c9f22f4a7429f812214407f2", + "reflex/components/datadisplay/shiki_code_block.pyi": "1d53e75b6be0d3385a342e7b3011babd", "reflex/components/el/__init__.pyi": "0adfd001a926a2a40aee94f6fa725ecc", - "reflex/components/el/element.pyi": "a14561a3018c8c64fcdb64b467efccc2", + "reflex/components/el/element.pyi": "c5974a92fbc310e42d0f6cfdd13472f4", "reflex/components/el/elements/__init__.pyi": "29512d7a6b29c6dc5ff68d3b31f26528", - "reflex/components/el/elements/base.pyi": "7c272b0fd1172e1840a66975f5df51a9", - "reflex/components/el/elements/forms.pyi": "d5d57536de6eedb9d13a1027f8621040", - "reflex/components/el/elements/inline.pyi": "d286cbe92244d624276b3d799809775f", - "reflex/components/el/elements/media.pyi": "66e9e478b61a1baff2b4d7cc75829a31", - "reflex/components/el/elements/metadata.pyi": "007a6c99cba81a8b8a9e007de94695a0", - "reflex/components/el/elements/other.pyi": "f392a93d23de3a78f872f06fbc403cb2", - "reflex/components/el/elements/scripts.pyi": "38085cfbf36fe98abbbb677096cdd730", - "reflex/components/el/elements/sectioning.pyi": "3ea78aa78769736806918e580f10990b", - "reflex/components/el/elements/tables.pyi": "0a2f098afa0b721fc47aedb0ed5e820a", - "reflex/components/el/elements/typography.pyi": "45deb16a45ecd54f83e85cdcdadb6eff", - "reflex/components/gridjs/datatable.pyi": "f0032aa28d3adbc50f3fe6f78ae1e13f", - "reflex/components/lucide/icon.pyi": "66ce989b327081bb5fb99d88ecfd5aa2", - "reflex/components/markdown/markdown.pyi": "6de3ce56d873c34e954491f5a8469329", - "reflex/components/moment/moment.pyi": "32a17f6324e3805ecbf3ff3a862704b6", - "reflex/components/plotly/plotly.pyi": "f337888586f51463da1e7886ca84a40b", + "reflex/components/el/elements/base.pyi": "3f74c7ea573ea29b055b0cd48b040d2c", + "reflex/components/el/elements/forms.pyi": "8b6bb2fbaf4bad828b076e2f7c8444d0", + "reflex/components/el/elements/inline.pyi": "3549cd6ad45217aa6387800911b641c3", + "reflex/components/el/elements/media.pyi": "9b97220aa99783d402b6e278c4069043", + "reflex/components/el/elements/metadata.pyi": "24448004b7aa07f1225028a85bd49fef", + "reflex/components/el/elements/other.pyi": "0c4d5d0b955d8596bf6cf4a48d7decdb", + "reflex/components/el/elements/scripts.pyi": "d33df9f21f7e838376b2b5024beef7c9", + "reflex/components/el/elements/sectioning.pyi": "3c5a7e4caa9c25da0ae788f02466eac4", + "reflex/components/el/elements/tables.pyi": "686eb70ea7d8c4dafb0cc5c284e76184", + "reflex/components/el/elements/typography.pyi": "684e83dde887dba12badd0fb75c87c04", + "reflex/components/gridjs/datatable.pyi": "98a7e1b3f3b60cafcdfcd8879750ee42", + "reflex/components/lucide/icon.pyi": "dcb8773ef162f3ec5759efe11374cf5e", + "reflex/components/markdown/markdown.pyi": "dd74e8e9665b2a813ff799a7aa190b44", + "reflex/components/moment/moment.pyi": "e1952f1c2c82cef85d91e970d1be64ab", + "reflex/components/plotly/plotly.pyi": "4311a0aae2abcc9226abb6a273f96372", "reflex/components/radix/__init__.pyi": "5d8e3579912473e563676bfc71f29191", "reflex/components/radix/primitives/__init__.pyi": "01c388fe7a1f5426a16676404344edf6", - "reflex/components/radix/primitives/accordion.pyi": "dd6c588ffeb3d571bd511401a4be4b88", - "reflex/components/radix/primitives/base.pyi": "1206ab73a5d6783e8476c9069f6fdadd", - "reflex/components/radix/primitives/dialog.pyi": "5344733f183a8926714953f58c7eff22", - "reflex/components/radix/primitives/drawer.pyi": "a640e0849f3a9c386edadba468a82761", - "reflex/components/radix/primitives/form.pyi": "afa914dbb20222dd1d27b61903e05490", - "reflex/components/radix/primitives/progress.pyi": "213896963b9f3c920c0d1e344e9cca3e", - "reflex/components/radix/primitives/slider.pyi": "0f80ec7a9b4a04e94f0dc8bc602bae0a", + "reflex/components/radix/primitives/accordion.pyi": "19484eca0ad53f538f5db04c09921738", + "reflex/components/radix/primitives/base.pyi": "9ef34884fb6028dc017df5e2db639c81", + "reflex/components/radix/primitives/dialog.pyi": "9ee73362bb59619c482b6b0d07033f37", + "reflex/components/radix/primitives/drawer.pyi": "921e45dfaf5b9131ef27c561c3acca2e", + "reflex/components/radix/primitives/form.pyi": "78055e820703c98c3b838aa889566365", + "reflex/components/radix/primitives/progress.pyi": "c917952d57ddb3e138a40c4005120d5e", + "reflex/components/radix/primitives/slider.pyi": "4ff06f0025d47f166132909b09ab96f8", "reflex/components/radix/themes/__init__.pyi": "582b4a7ead62b2ae8605e17fa084c063", - "reflex/components/radix/themes/base.pyi": "b37be1a57015f405fdd4777c448474b3", - "reflex/components/radix/themes/color_mode.pyi": "3e5c86200feb8a99bf03a2fbe58a7718", + "reflex/components/radix/themes/base.pyi": "3e1ccd5ce5fef0b2898025193ee3d069", + "reflex/components/radix/themes/color_mode.pyi": "dda570583355d8c0d8f607be457ba7a1", "reflex/components/radix/themes/components/__init__.pyi": "efa279ee05479d7bb8a64d49da808d03", - "reflex/components/radix/themes/components/alert_dialog.pyi": "18f602447492a47b577680ed7b5567fb", - "reflex/components/radix/themes/components/aspect_ratio.pyi": "f1b976613b5b12edcc80be730511b04f", - "reflex/components/radix/themes/components/avatar.pyi": "8e3797ab7df3fca0d9092d96aee84d61", - "reflex/components/radix/themes/components/badge.pyi": "8b51f4ed128fa13ee5ab2f682b0b3176", - "reflex/components/radix/themes/components/button.pyi": "d55ce7bdea1403e6a0efa1dbfc651a61", - "reflex/components/radix/themes/components/callout.pyi": "138e6e1def9c9153f71b0a018cb6c4ce", - "reflex/components/radix/themes/components/card.pyi": "e840f93caaea8a0c76c26b4035fdcb64", - "reflex/components/radix/themes/components/checkbox.pyi": "74b0293a653eb0933e8bd5e27d4c805e", - "reflex/components/radix/themes/components/checkbox_cards.pyi": "3d0fd3bdce49e94b0ad8d0c726e7a941", - "reflex/components/radix/themes/components/checkbox_group.pyi": "d858f46332e1f1f9ff3200399c9520a5", - "reflex/components/radix/themes/components/context_menu.pyi": "6052570ea73008270486bccc23631bc8", - "reflex/components/radix/themes/components/data_list.pyi": "9f0635ca222dbeebd5be235f8d4b3e8d", - "reflex/components/radix/themes/components/dialog.pyi": "4f49cd0ffee79f3caba93042df56be28", - "reflex/components/radix/themes/components/dropdown_menu.pyi": "0d5a228ea7d81470ff85ae2333ec5b07", - "reflex/components/radix/themes/components/hover_card.pyi": "28033f1354579725e6ece952758ddbc7", - "reflex/components/radix/themes/components/icon_button.pyi": "61d131968283ff9bf2e70054ac3e1a5d", - "reflex/components/radix/themes/components/inset.pyi": "8d4b238c5c5d4d72254889a7b7adc016", - "reflex/components/radix/themes/components/popover.pyi": "49aef73b29362ee4c2ef4ccfdbcc3adc", - "reflex/components/radix/themes/components/progress.pyi": "8a32157eb666f8e47c9716acb119683d", - "reflex/components/radix/themes/components/radio.pyi": "a1f55d90f75dbaf36c03b4c57dfb1e00", - "reflex/components/radix/themes/components/radio_cards.pyi": "bbbe53ffdb72bbab2b635f54745b9fc8", - "reflex/components/radix/themes/components/radio_group.pyi": "28a6d9ac8f8d3c3b3402430bcc083a50", - "reflex/components/radix/themes/components/scroll_area.pyi": "caff5f39bc7fdad3aad750ae60c0171d", - "reflex/components/radix/themes/components/segmented_control.pyi": "d1afa3691578a36c6e4229757b0ca17a", - "reflex/components/radix/themes/components/select.pyi": "ed28086948f174ee8776b8481a3cd688", - "reflex/components/radix/themes/components/separator.pyi": "d40d4fe3c9b6d33dcadc4459cb5059f0", - "reflex/components/radix/themes/components/skeleton.pyi": "89d55ceec7aa035433c2a02432c15ace", - "reflex/components/radix/themes/components/slider.pyi": "5d9fd5475b1e545ca6850953647163d8", - "reflex/components/radix/themes/components/spinner.pyi": "016a46dc3ecdaf9bbdd404925babe3ca", - "reflex/components/radix/themes/components/switch.pyi": "2f260c4a33f05dc4d79e34a519e5f816", - "reflex/components/radix/themes/components/table.pyi": "7256e33b9afab10cb86e235b351a01d2", - "reflex/components/radix/themes/components/tabs.pyi": "fb9c38c1ee45b1c0806bc917141a2b87", - "reflex/components/radix/themes/components/text_area.pyi": "b95ee08c4af639b6c10e68b845baef8f", - "reflex/components/radix/themes/components/text_field.pyi": "c16732cfd8960cc395bbc0516f7819ef", - "reflex/components/radix/themes/components/tooltip.pyi": "7a1f5ccfac8088a61a880a8b19cc5990", + "reflex/components/radix/themes/components/alert_dialog.pyi": "eed422fcc1ff5ccf3dbf6934699bd0b1", + "reflex/components/radix/themes/components/aspect_ratio.pyi": "71de4160d79840561c48b570197a4152", + "reflex/components/radix/themes/components/avatar.pyi": "e40c2f0fda6d2c028d83681a27f3fb96", + "reflex/components/radix/themes/components/badge.pyi": "58fd1a9c5d2f8762e2a0370311731ff5", + "reflex/components/radix/themes/components/button.pyi": "50f0b08ad5d1d1054ab537152f0f5c43", + "reflex/components/radix/themes/components/callout.pyi": "547f2570ffbd10db36b745566e9f1b17", + "reflex/components/radix/themes/components/card.pyi": "f7adb83f7b001a11bdd7fd6791fb3ffb", + "reflex/components/radix/themes/components/checkbox.pyi": "8eabb6887a5d0849a43e086a284814c2", + "reflex/components/radix/themes/components/checkbox_cards.pyi": "1d567fd04b4425abd5cc5aad10108aa9", + "reflex/components/radix/themes/components/checkbox_group.pyi": "8638582a623036f8893a3fa6080f2672", + "reflex/components/radix/themes/components/context_menu.pyi": "b9499d8bdd2c5565621fea5fe7d7a25a", + "reflex/components/radix/themes/components/data_list.pyi": "6f8d9c582e084c23966b992158193b72", + "reflex/components/radix/themes/components/dialog.pyi": "d2615f1a68c80ff930444d054b598c13", + "reflex/components/radix/themes/components/dropdown_menu.pyi": "43f8770c9adf93c73398d68f79048424", + "reflex/components/radix/themes/components/hover_card.pyi": "a96f4433237f9994decf935deff9f269", + "reflex/components/radix/themes/components/icon_button.pyi": "e930911d8ecbe61e5447e61c76a28ab6", + "reflex/components/radix/themes/components/inset.pyi": "bd7a2186b553bd4c86d83ff50c784066", + "reflex/components/radix/themes/components/popover.pyi": "91f8edefeb232cc6d48690b1838144c2", + "reflex/components/radix/themes/components/progress.pyi": "0e59587d5b3c8fe0d0067587f144e5b0", + "reflex/components/radix/themes/components/radio.pyi": "f375aa5ac746679618ea7dad257e3224", + "reflex/components/radix/themes/components/radio_cards.pyi": "9dc34a1ce2a1924eb1f41438ef84e80b", + "reflex/components/radix/themes/components/radio_group.pyi": "173254cf91908bcf6aa4fa21a747e2cf", + "reflex/components/radix/themes/components/scroll_area.pyi": "2e3539b0f6895dda127ee96e9864dbf9", + "reflex/components/radix/themes/components/segmented_control.pyi": "1776f1ad936bae402007802b1ee98906", + "reflex/components/radix/themes/components/select.pyi": "2c7aee592972ff5f05da08154aa981c8", + "reflex/components/radix/themes/components/separator.pyi": "79e550cc10ee455f35d75d0e236fedd2", + "reflex/components/radix/themes/components/skeleton.pyi": "a25d3ceb56f99f736ea463579845c454", + "reflex/components/radix/themes/components/slider.pyi": "305a34c14ca8656ca9267e4c31aaa388", + "reflex/components/radix/themes/components/spinner.pyi": "b7e689e7d75635e379242fd113a1ea9a", + "reflex/components/radix/themes/components/switch.pyi": "f1ba948750a74126cda990e89a3ec7ef", + "reflex/components/radix/themes/components/table.pyi": "eefbbd1904deae3d166fcad28b20fd4a", + "reflex/components/radix/themes/components/tabs.pyi": "a533d2509a6798fe0ab7275b0152519d", + "reflex/components/radix/themes/components/text_area.pyi": "4af55e5d18a5b9d56717bf31b23ea543", + "reflex/components/radix/themes/components/text_field.pyi": "232618b744076db98d861ea1b9eb3192", + "reflex/components/radix/themes/components/tooltip.pyi": "2b8366200ce92ec4784ca3ec4152e676", "reflex/components/radix/themes/layout/__init__.pyi": "73eefc509a49215b1797b5b5d28d035e", - "reflex/components/radix/themes/layout/base.pyi": "a63a07e11c6bc49c25b5da476d9ce38d", - "reflex/components/radix/themes/layout/box.pyi": "2421795671726996bd976a00d8d2378a", - "reflex/components/radix/themes/layout/center.pyi": "0c9c2d444f94f404ee12c5c482151b34", - "reflex/components/radix/themes/layout/container.pyi": "737db1b092eb6b424060d35d8789e225", - "reflex/components/radix/themes/layout/flex.pyi": "ae3cd66acaee576a4a24c070514e510c", - "reflex/components/radix/themes/layout/grid.pyi": "79ae4534482f47000bb035bd2f73ac79", - "reflex/components/radix/themes/layout/list.pyi": "5dfd59f0c5dae70b25a34372350449b6", - "reflex/components/radix/themes/layout/section.pyi": "19ccfe0ec887a340b62f6488dfb438e5", - "reflex/components/radix/themes/layout/spacer.pyi": "93c6346a609fd50482986278c8cb2428", - "reflex/components/radix/themes/layout/stack.pyi": "c34a7143f9fa188d9f05fdef89add9ab", + "reflex/components/radix/themes/layout/base.pyi": "5be31d7dadd23ab544e53762423d123e", + "reflex/components/radix/themes/layout/box.pyi": "dbaed1c50c668805fc7b71d22f878254", + "reflex/components/radix/themes/layout/center.pyi": "17323694217e8ad7611adb683f8d96ce", + "reflex/components/radix/themes/layout/container.pyi": "24222fd7ffa2dc05f709eab6c7b9643c", + "reflex/components/radix/themes/layout/flex.pyi": "0307e9dbe6a5784140121d77c8f67a86", + "reflex/components/radix/themes/layout/grid.pyi": "95c9edb8bdd4e39dc1bd6bc2a8ca0933", + "reflex/components/radix/themes/layout/list.pyi": "049ecf827ef0ba8de2d76dbf7b1c562c", + "reflex/components/radix/themes/layout/section.pyi": "a51952b9b5c8227aa3024373dedcad5d", + "reflex/components/radix/themes/layout/spacer.pyi": "c35accf0f2f742c90a23675ff1fb960d", + "reflex/components/radix/themes/layout/stack.pyi": "271d3315c6196356d3ced759520d4e7d", "reflex/components/radix/themes/typography/__init__.pyi": "b8ef970530397e9984004961f3aaee62", - "reflex/components/radix/themes/typography/blockquote.pyi": "752543a6b5677527192894372271f405", - "reflex/components/radix/themes/typography/code.pyi": "bf554d2f5ac94cbf25a3eb8af0cc4c6c", - "reflex/components/radix/themes/typography/heading.pyi": "47d6861eb716c75e82d546fd8a26c793", - "reflex/components/radix/themes/typography/link.pyi": "4e3cad3a03933f6c8b1a61b9a96ce913", - "reflex/components/radix/themes/typography/text.pyi": "0218fb9f08667f88315cec27689dd084", - "reflex/components/react_player/audio.pyi": "e82dabd3316dbd4c08dfea4a4eaf6865", - "reflex/components/react_player/react_player.pyi": "a2b606bff3c4964a2bcb25cfa8ffa68d", - "reflex/components/react_player/video.pyi": "d02f2b6d5ff8ed328a01224439459ec8", - "reflex/components/react_router/dom.pyi": "e2c9489e73d35d0a069d7a4f81ff7f8b", + "reflex/components/radix/themes/typography/blockquote.pyi": "080c71899532f5dbf4cf143e7a5ad3bf", + "reflex/components/radix/themes/typography/code.pyi": "7ffe785d55979cf8ff97ea040f3e2b64", + "reflex/components/radix/themes/typography/heading.pyi": "0ebb38915cd0521fd59c569e04d288bb", + "reflex/components/radix/themes/typography/link.pyi": "e88c5d880a54548b6808c097ac62505b", + "reflex/components/radix/themes/typography/text.pyi": "50f9ca15a941e4b77ddd12e77aa3c03e", + "reflex/components/react_player/audio.pyi": "0e1690ff1f1f39bc748278d292238350", + "reflex/components/react_player/react_player.pyi": "5ccd373b94ed1d3934ae6afc46bd6fe4", + "reflex/components/react_player/video.pyi": "998671c06103d797c554d9278eb3b2a0", + "reflex/components/react_router/dom.pyi": "3042fa630b7e26a7378fe045d7fbf4af", "reflex/components/recharts/__init__.pyi": "6ee7f1ca2c0912f389ba6f3251a74d99", - "reflex/components/recharts/cartesian.pyi": "cc829e71a9b8b804a46195bbc1fea784", - "reflex/components/recharts/charts.pyi": "a866fe6c7add355a789981f6a0c6efa4", - "reflex/components/recharts/general.pyi": "ccabc476864c95cd61b705933fc341ac", - "reflex/components/recharts/polar.pyi": "4327e685e14b13ecc84380d455175a03", - "reflex/components/recharts/recharts.pyi": "fa19c2b678ed5994f261c500397b644d", - "reflex/components/sonner/toast.pyi": "f5f6a7f0f690ebd19f4405aca28d7217" + "reflex/components/recharts/cartesian.pyi": "d138261ab8259d5208c2f028b9f708bd", + "reflex/components/recharts/charts.pyi": "013036b9c00ad85a570efdb813c1bc40", + "reflex/components/recharts/general.pyi": "d87ff9b85b2a204be01753690df4fb11", + "reflex/components/recharts/polar.pyi": "b8b1a3e996e066facdf4f8c9eb363137", + "reflex/components/recharts/recharts.pyi": "d5c9fc57a03b419748f0408c23319eee", + "reflex/components/sonner/toast.pyi": "3c27bad1aaeb5183eaa6a41e77e8d7f0" } From 2feca156ba5571df3896333efa4551c3856602f2 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 15 Mar 2026 12:51:23 +0100 Subject: [PATCH 09/10] fix js formatting --- reflex/.templates/web/generate-shell.mjs | 3 ++- reflex/.templates/web/ssr-serve.js | 7 ++++--- tests/integration/tests_playwright/test_ssr.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/reflex/.templates/web/generate-shell.mjs b/reflex/.templates/web/generate-shell.mjs index 9ac976d08c4..2e60cc33392 100644 --- a/reflex/.templates/web/generate-shell.mjs +++ b/reflex/.templates/web/generate-shell.mjs @@ -15,7 +15,8 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; // Resolve paths relative to this file, not process.cwd(). -const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); +const __dirname = + import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); const build = await import(join(__dirname, "build", "server", "index.js")); const handler = createRequestHandler(build, "production"); diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js index 8d439fc7089..17ede22971e 100644 --- a/reflex/.templates/web/ssr-serve.js +++ b/reflex/.templates/web/ssr-serve.js @@ -15,7 +15,8 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; // Resolve all paths relative to *this file*, not process.cwd(). -const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); +const __dirname = + import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); const clientDir = join(__dirname, "build", "client"); const serverEntry = join(__dirname, "build", "server", "index.js"); @@ -29,7 +30,7 @@ app.use(compression()); // Static assets with content-hash filenames — cache immutably. app.use( "/assets", - express.static(join(clientDir, "assets"), { immutable: true, maxAge: "1y" }) + express.static(join(clientDir, "assets"), { immutable: true, maxAge: "1y" }), ); // Other static files (favicon, sitemap, etc.) — short cache. @@ -43,7 +44,7 @@ const shellPath = join(clientDir, "index.html"); const hasShell = existsSync(shellPath); if (!hasShell) { console.warn( - "[ssr-serve] build/client/index.html not found — all requests will use SSR." + "[ssr-serve] build/client/index.html not found — all requests will use SSR.", ); } diff --git a/tests/integration/tests_playwright/test_ssr.py b/tests/integration/tests_playwright/test_ssr.py index 2d82d7222c5..ab9205129d0 100644 --- a/tests/integration/tests_playwright/test_ssr.py +++ b/tests/integration/tests_playwright/test_ssr.py @@ -197,10 +197,10 @@ def test_direct_access_blog_post(ssr_blog_app: AppHarnessSSR, page: Page): expect(title).to_have_text("Hello World") content = page.get_by_test_id("post-content") - expect(content).to_have_text("First post content for SSR testing.") + expect(content).to_have_text("First post content for SSR testing.", timeout=15000) author = page.get_by_test_id("post-author") - expect(author).to_have_text("Test Author") + expect(author).to_have_text("Test Author", timeout=15000) def test_client_navigation(ssr_blog_app: AppHarnessSSR, page: Page): From 0274b8eb0a38fd60186fd92876b965c9e666d218 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 15 Mar 2026 12:57:44 +0100 Subject: [PATCH 10/10] cache this --- reflex/.templates/web/ssr-serve.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js index 17ede22971e..17249281a39 100644 --- a/reflex/.templates/web/ssr-serve.js +++ b/reflex/.templates/web/ssr-serve.js @@ -10,7 +10,7 @@ import { createRequestHandler } from "@react-router/express"; import express from "express"; import compression from "compression"; import { isbot } from "isbot"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -39,10 +39,13 @@ app.use(express.static(clientDir, { maxAge: "1h" })); // SSR request handler (React Router). const ssrHandler = createRequestHandler({ build: buildModule }); -// Check if the static SPA shell exists (generated by generate-shell.mjs). +// Read the static SPA shell into memory at startup (generated by generate-shell.mjs). +// Serving from memory avoids per-request filesystem access. const shellPath = join(clientDir, "index.html"); -const hasShell = existsSync(shellPath); -if (!hasShell) { +const shellHtml = existsSync(shellPath) + ? readFileSync(shellPath, "utf-8") + : null; +if (!shellHtml) { console.warn( "[ssr-serve] build/client/index.html not found — all requests will use SSR.", ); @@ -53,7 +56,7 @@ app.all("*", (req, res, next) => { // Bots always get full server-side rendered HTML with state data. // Also used as fallback when the static shell is unavailable. - if (isbot(ua) || !hasShell) { + if (isbot(ua) || !shellHtml) { return ssrHandler(req, res, next); } @@ -63,7 +66,9 @@ app.all("*", (req, res, next) => { // the SSR handler so the root loader can run and return JSON state data. const accept = req.headers["accept"] || ""; if (accept.includes("text/html") && !req.url.endsWith(".data")) { - return res.sendFile(shellPath); + return res + .setHeader("Content-Type", "text/html; charset=utf-8") + .send(shellHtml); } // .data requests, API calls, etc. → SSR handler (runs loaders, returns JSON).