From 2272cc0b5d861615c661b51ba03096e6b380fb96 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 2 Mar 2026 01:00:28 +0100 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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). From 257ed7915d3990593472332af7cebbb404f75342 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 25 Apr 2026 21:23:03 +0200 Subject: [PATCH 11/15] wip --- reflex/.templates/web/ssr-serve.js | 20 ++-- reflex/__init__.py | 2 +- reflex/app.py | 12 +- reflex/compiler/compiler.py | 32 +++--- reflex/compiler/templates.py | 25 +++-- reflex/config.py | 4 +- reflex/constants/__init__.py | 2 + reflex/constants/base.py | 13 +++ reflex/constants/installer.py | 2 +- reflex/testing.py | 29 +++-- reflex/utils/build.py | 6 +- reflex/utils/exec.py | 2 + reflex/utils/frontend_skeleton.py | 9 +- .../integration/tests_playwright/test_ssr.py | 2 +- tests/units/test_prerequisites.py | 5 +- tests/units/test_ssr_compile.py | 105 ++++++++++-------- 16 files changed, 161 insertions(+), 109 deletions(-) diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js index 17249281a39..d84f3d0961c 100644 --- a/reflex/.templates/web/ssr-serve.js +++ b/reflex/.templates/web/ssr-serve.js @@ -1,10 +1,11 @@ /** - * Bot-aware SSR production server for Reflex apps. + * 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). + * Supports two modes (controlled by the SSR_MODE env var): + * - "bot_only" (default): Bots get SSR HTML; regular users get the SPA shell. + * - "always": All users get server-side rendered HTML. * - * Used when `runtime_ssr=True` is set in the Reflex config. + * Used when `ssr_mode` is not "off" in the Reflex config. */ import { createRequestHandler } from "@react-router/express"; import express from "express"; @@ -51,12 +52,17 @@ if (!shellHtml) { ); } +// SSR mode: "bot_only" serves the SPA shell to regular users and SSR to bots; +// "always" serves SSR to everyone. +const ssrMode = process.env.SSR_MODE || "bot_only"; + 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) || !shellHtml) { + // In "always" mode every request gets SSR. + // In "bot_only" mode only bots get SSR. + // Fallback to SSR when the static shell is unavailable. + if (ssrMode === "always" || isbot(ua) || !shellHtml) { return ssrHandler(req, res, next); } diff --git a/reflex/__init__.py b/reflex/__init__.py index 066df110f02..fdf35b960ab 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -295,7 +295,7 @@ "components.gridjs": ["data_table"], "components.moment": ["MomentDelta", "moment"], "config": ["Config", "DBConfig"], - "constants": ["Env"], + "constants": ["Env", "SsrMode"], "constants.colors": ["Color"], "event": [ "event", diff --git a/reflex/app.py b/reflex/app.py index d89143eb6ca..a44c129b906 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -690,7 +690,7 @@ def _add_default_endpoints(self): health, methods=["GET"], ) - if get_config().runtime_ssr: + if get_config().ssr_mode != constants.SsrMode.OFF: self._api.add_route( str(constants.Endpoint.SSR_DATA), ssr_data(self), @@ -1468,9 +1468,9 @@ def _submit_work_without_advancing( progress.advance(task) # Compile the contexts. - runtime_ssr = get_config().runtime_ssr + ssr_mode = get_config().ssr_mode compile_results.append( - compiler.compile_contexts(self._state, self.theme, runtime_ssr=runtime_ssr), + compiler.compile_contexts(self._state, self.theme, ssr_mode=ssr_mode), ) if self.theme is not None: # Fix #2992 by removing the top-level appearance prop @@ -1479,7 +1479,7 @@ def _submit_work_without_advancing( # Compile the app root. compile_results.append( - compiler.compile_app(app_root, runtime_ssr=runtime_ssr), + compiler.compile_app(app_root, ssr_mode=ssr_mode), ) progress.advance(task) @@ -1498,8 +1498,8 @@ def _submit_work_without_advancing( ) frontend_skeleton.initialize_package_json() - # Copy SSR scripts when runtime SSR is enabled. - if runtime_ssr: + # Copy SSR scripts when SSR is enabled. + if ssr_mode != constants.SsrMode.OFF: frontend_skeleton.copy_ssr_scripts() if is_prod_mode(): diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 9aec8ac8934..a838ec8c177 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -73,12 +73,15 @@ def _normalize_library_name(lib: str) -> str: return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") -def _compile_app(app_root: Component, runtime_ssr: bool = False) -> str: +def _compile_app( + app_root: Component, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, +) -> str: """Compile the app template component. Args: app_root: The app root to compile. - runtime_ssr: Whether runtime SSR is enabled. + ssr_mode: The server-side rendering mode. Returns: The compiled app. @@ -101,7 +104,7 @@ def _compile_app(app_root: Component, runtime_ssr: bool = False) -> str: window_libraries=window_libraries_deduped, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), - runtime_ssr=runtime_ssr, + ssr_mode=ssr_mode, ) @@ -120,14 +123,14 @@ def _compile_theme(theme: str) -> str: def _compile_contexts( state: type[BaseState] | None, theme: Component | None, - runtime_ssr: bool = False, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ) -> 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. + ssr_mode: The server-side rendering mode. Returns: The compiled context file. @@ -143,13 +146,13 @@ def _compile_contexts( client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), - runtime_ssr=runtime_ssr, + ssr_mode=ssr_mode, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), - runtime_ssr=runtime_ssr, + ssr_mode=ssr_mode, ) ) @@ -500,12 +503,15 @@ def compile_document_root( return output_path, code -def compile_app(app_root: Component, runtime_ssr: bool = False) -> tuple[str, str]: +def compile_app( + app_root: Component, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, +) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. - runtime_ssr: Whether runtime SSR is enabled. + ssr_mode: The server-side rendering mode. Returns: The path and code of the compiled app wrapper. @@ -516,7 +522,7 @@ def compile_app(app_root: Component, runtime_ssr: bool = False) -> tuple[str, st ) # Compile the document root. - code = _compile_app(app_root, runtime_ssr=runtime_ssr) + code = _compile_app(app_root, ssr_mode=ssr_mode) return output_path, code @@ -542,14 +548,14 @@ def compile_theme(style: ComponentStyle) -> tuple[str, str]: def compile_contexts( state: type[BaseState] | None, theme: Component | None, - runtime_ssr: bool = False, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ) -> 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. + ssr_mode: The server-side rendering mode. Returns: The path and code of the compiled context. @@ -557,7 +563,7 @@ def compile_contexts( # Get the path for the output file. output_path = utils.get_context_path() - return output_path, _compile_contexts(state, theme, runtime_ssr=runtime_ssr) + return output_path, _compile_contexts(state, theme, ssr_mode=ssr_mode) def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 329d3ffe316..e087cd4e755 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -170,7 +170,7 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], - runtime_ssr: bool = False, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ): """Template for the App root. @@ -181,7 +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. + ssr_mode: The server-side rendering mode. Returns: Rendered App root component as string. @@ -200,7 +200,7 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) - if runtime_ssr: + if ssr_mode != constants.SsrMode.OFF: ssr_imports = ( 'import { Outlet, useLoaderData } from "react-router";\n' 'import { getBackendURL } from "$/utils/state";\n' @@ -314,7 +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, + ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ): """Template for the context file. @@ -324,7 +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. + ssr_mode: The server-side rendering mode. Returns: Rendered context file content as string. @@ -382,7 +382,8 @@ def context_template( """ ) - if runtime_ssr: + ssr_enabled = ssr_mode != constants.SsrMode.OFF + if ssr_enabled: 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 @@ -417,7 +418,7 @@ def context_template( export const EventLoopContext = createContext(null);{ ''' export const SSRContext = createContext(false);''' - if runtime_ssr + if ssr_enabled else "" } export const clientStorage = { @@ -460,7 +461,7 @@ def context_template( const dispatch = useContext(DispatchContext){ ''' const ssrHydrated = useContext(SSRContext)''' - if runtime_ssr + if ssr_enabled else "" } const [addEvents, connectErrors] = useEventLoop( @@ -469,7 +470,7 @@ def context_template( clientStorage,{ ''' ssrHydrated,''' - if runtime_ssr + if ssr_enabled else "" } ) @@ -481,7 +482,7 @@ def context_template( }} export function StateProvider({{ children{ - ", ssrState = null" if runtime_ssr else "" + ", ssrState = null" if ssr_enabled else "" } }}) {{ {state_reducer_str} const dispatchers = useMemo(() => {{ @@ -491,11 +492,11 @@ def context_template( }}, []) return ( - {"createElement(SSRContext.Provider, {value: !!ssrState}," if runtime_ssr else ""} + {"createElement(SSRContext.Provider, {value: !!ssrState}," if ssr_enabled else ""} {create_state_contexts_str} createElement(DispatchContext, {{value: dispatchers}}, children) {")" * len(initial_state)} - {")" if runtime_ssr else ""} + {")" if ssr_enabled else ""} ) }}""" diff --git a/reflex/config.py b/reflex/config.py index 6a9c38d8312..ac683e11a71 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -265,8 +265,8 @@ 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 + # Server-side rendering mode: "off" (default), "bot_only", or "always". + ssr_mode: constants.SsrMode = constants.SsrMode.OFF # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index 69f79271d9e..64d8d07c6c5 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -20,6 +20,7 @@ ReactRouter, Reflex, ReflexHostingCLI, + SsrMode, Templates, ) from .compiler import ( @@ -113,6 +114,7 @@ "RouteRegex", "RouteVar", "SocketEvent", + "SsrMode", "StateManagerMode", "Templates", ] diff --git a/reflex/constants/base.py b/reflex/constants/base.py index bbc39afcd6e..08173c3edfe 100644 --- a/reflex/constants/base.py +++ b/reflex/constants/base.py @@ -201,6 +201,19 @@ class Env(str, Enum): PROD = "prod" +class SsrMode(str, Enum): + """Server-side rendering modes. + + OFF: No SSR — static SPA (default, same as before). + BOT_ONLY: SSR for crawlers/bots only — regular users get the SPA shell. + ALWAYS: SSR for all users — better FCP/LCP, higher server load. + """ + + OFF = "off" + BOT_ONLY = "bot_only" + ALWAYS = "always" + + # Log levels class LogLevel(str, Enum): """The log levels.""" diff --git a/reflex/constants/installer.py b/reflex/constants/installer.py index 84801fede16..1645bc5264c 100644 --- a/reflex/constants/installer.py +++ b/reflex/constants/installer.py @@ -153,7 +153,7 @@ def DEPENDENCIES(cls) -> dict[str, str]: @classproperty @classmethod def SSR_DEPENDENCIES(cls) -> dict[str, str]: - """Additional dependencies required when runtime_ssr is enabled. + """Additional dependencies required when ssr_mode is not OFF. Returns: A dictionary of SSR-specific dependencies with their versions. diff --git a/reflex/testing.py b/reflex/testing.py index fefb522cf8f..1d2fc673bb4 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -1137,28 +1137,29 @@ def stop(self): class AppHarnessSSR(AppHarnessProd): - """AppHarnessSSR runs a Reflex app with runtime SSR via ssr-serve.js. + """AppHarnessSSR runs a Reflex app with 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``. + Use this harness with ``ssr_mode="bot_only"`` (or ``"always"``) in + ``rxconfig.py``. """ def _initialize_app(self): - """Initialize the app and patch the config for runtime SSR. + """Initialize the app and patch the config for 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. + endpoints). We need ``ssr_mode`` set **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``. + 2. Set the live config ``ssr_mode`` to ``BOT_ONLY``. 3. Manually register the ``/_ssr_data`` endpoint on the already-created ASGI app (since it was skipped during initial creation). """ @@ -1167,11 +1168,11 @@ def _initialize_app(self): # 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,") + content = content.replace("rx.Config(", 'rx.Config(\n ssr_mode="bot_only",') rxconfig_path.write_text(content) - # 2. Enable runtime_ssr on the live config singleton. - get_config().runtime_ssr = True + # 2. Enable SSR on the live config singleton. + get_config().ssr_mode = reflex.constants.SsrMode.BOT_ONLY # 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: @@ -1190,7 +1191,7 @@ def _start_frontend(self): 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``). + harness flipped ``ssr_mode`` after the first ``_init``). """ with chdir(self.app_path): config = reflex.config.get_config() @@ -1238,7 +1239,11 @@ def _start_frontend(self): self.frontend_process = reflex.utils.processes.new_process( [node, "ssr-serve.js"], cwd=web_dir, - env={"PORT": "0", "NO_COLOR": "1"}, + env={ + "PORT": "0", + "NO_COLOR": "1", + "SSR_MODE": get_config().ssr_mode.value, + }, **FRONTEND_POPEN_ARGS, ) diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 45bff5e7865..61008dd8818 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -142,7 +142,7 @@ def zip_app( if frontend: web_dir = prerequisites.get_web_dir() - if get_config().runtime_ssr: + if get_config().ssr_mode != constants.SsrMode.OFF: # 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( @@ -280,8 +280,8 @@ def build(): ) raise SystemExit(1) - # When runtime SSR is enabled, generate a static SPA shell for non-bot users. - if get_config().runtime_ssr: + # When SSR is enabled, generate a static SPA shell for non-bot users. + if get_config().ssr_mode != constants.SsrMode.OFF: _generate_ssr_shell(wdir) _duplicate_index_html_to_parent_directory(wdir / constants.Dirs.STATIC) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c67976b23b2..d0312d4fa71 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -276,6 +276,8 @@ def run_frontend_prod(root: Path, port: str, backend_present: bool = True): # Set the port. os.environ["PORT"] = str(get_config().frontend_port if port is None else port) + # Pass the SSR mode to ssr-serve.js (only relevant when ssr_mode != OFF). + os.environ["SSR_MODE"] = get_config().ssr_mode.value # validate dependencies before run js_runtimes.validate_frontend_dependencies(init=False) # Run the frontend in production mode. diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index afcbe00a282..4a6a0a23ec1 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -151,7 +151,7 @@ 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. + (post-build static shell generator) when SSR is enabled. """ import shutil @@ -172,7 +172,7 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): "future": { "unstable_optimizeDeps": True, }, - "ssr": config.runtime_ssr, + "ssr": config.ssr_mode != constants.SsrMode.OFF, } if prerender_routes: @@ -184,13 +184,14 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): def _compile_package_json(): config = get_config() + ssr_enabled = config.ssr_mode != constants.SsrMode.OFF prod_command = ( constants.PackageJson.Commands.PROD_SSR - if config.runtime_ssr + if ssr_enabled else constants.PackageJson.Commands.get_prod_command(config.frontend_path) ) deps = dict(constants.PackageJson.DEPENDENCIES) - if config.runtime_ssr: + if ssr_enabled: deps.update(constants.PackageJson.SSR_DEPENDENCIES) return templates.package_json_template( scripts={ diff --git a/tests/integration/tests_playwright/test_ssr.py b/tests/integration/tests_playwright/test_ssr.py index ab9205129d0..7ccd62f080d 100644 --- a/tests/integration/tests_playwright/test_ssr.py +++ b/tests/integration/tests_playwright/test_ssr.py @@ -1,6 +1,6 @@ """Integration tests for runtime SSR (server-side rendering). -Spins up a blog app with ``runtime_ssr=True`` via ``AppHarnessSSR``, then +Spins up a blog app with ``ssr_mode="bot_only"`` via ``AppHarnessSSR``, then verifies: - Bots receive fully rendered HTML with blog data. - Normal users receive the SPA shell (no blog content in raw HTML). diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 58ea416e8d0..e2529b444d7 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -6,6 +6,7 @@ from click.testing import CliRunner from reflex.config import Config +from reflex.constants.base import SsrMode from reflex.constants.installer import PackageJson from reflex.reflex import cli from reflex.testing import chdir @@ -57,7 +58,7 @@ ( Config( app_name="test", - runtime_ssr=True, + ssr_mode=SsrMode.BOT_ONLY, ), False, 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true};', @@ -65,7 +66,7 @@ ( Config( app_name="test", - runtime_ssr=True, + ssr_mode=SsrMode.BOT_ONLY, ), True, 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true, "prerender": true, "build": "build"};', diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py index 888dd229f0f..1b52e1d280e 100644 --- a/tests/units/test_ssr_compile.py +++ b/tests/units/test_ssr_compile.py @@ -7,6 +7,7 @@ from pytest_mock import MockerFixture import reflex as rx +from reflex.constants import SsrMode from reflex.utils.frontend_skeleton import ( _compile_package_json, _update_react_router_config, @@ -14,11 +15,11 @@ class TestPackageJsonProdCommand: - """Tests for the package.json prod command based on runtime_ssr config.""" + """Tests for the package.json prod command based on ssr_mode 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) + """With ssr_mode=bot_only, prod command is 'node ssr-serve.js'.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.BOT_ONLY) mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) result = _compile_package_json() @@ -27,8 +28,8 @@ def test_prod_command_ssr(self, mocker: MockerFixture): 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) + """With ssr_mode=off, prod command is sirv static server.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.OFF) mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) result = _compile_package_json() @@ -38,56 +39,62 @@ def test_prod_command_static(self, mocker: MockerFixture): 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-specific deps are only included when ssr_mode != OFF.""" ssr_only_deps = ("@react-router/express", "express", "compression") - for ssr in (True, False): - conf = rx.Config(app_name="test", runtime_ssr=ssr) + for mode in (SsrMode.BOT_ONLY, SsrMode.OFF): + conf = rx.Config(app_name="test", ssr_mode=mode) 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" + if mode != SsrMode.OFF: + assert dep in deps, f"{dep} should be present when ssr_mode={mode}" else: assert dep not in deps, ( - f"{dep} should NOT be present when runtime_ssr=False" + f"{dep} should NOT be present when ssr_mode=off" ) def test_dev_and_export_commands_unchanged(self, mocker: MockerFixture): - """Dev and export commands are the same regardless of runtime_ssr.""" + """Dev and export commands are the same regardless of ssr_mode.""" results = {} - for ssr in (True, False): - conf = rx.Config(app_name="test", runtime_ssr=ssr) + for mode in (SsrMode.BOT_ONLY, SsrMode.OFF): + conf = rx.Config(app_name="test", ssr_mode=mode) mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) - results[ssr] = json.loads(_compile_package_json()) + results[mode] = json.loads(_compile_package_json()) - assert results[True]["scripts"]["dev"] == results[False]["scripts"]["dev"] - assert results[True]["scripts"]["export"] == results[False]["scripts"]["export"] + assert ( + results[SsrMode.BOT_ONLY]["scripts"]["dev"] + == results[SsrMode.OFF]["scripts"]["dev"] + ) + assert ( + results[SsrMode.BOT_ONLY]["scripts"]["export"] + == results[SsrMode.OFF]["scripts"]["export"] + ) class TestReactRouterConfig: - """Tests for react-router.config.js based on runtime_ssr config.""" + """Tests for react-router.config.js based on ssr_mode 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) + """With ssr_mode=bot_only, config has ssr: true.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.BOT_ONLY) 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) + """With ssr_mode=off, config has ssr: false.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.OFF) 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) + """ssr_mode and prerender can coexist.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.BOT_ONLY) result = _update_react_router_config(conf, prerender_routes=True) parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) @@ -102,16 +109,24 @@ def test_default_ssr_is_false(self): parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) assert parsed["ssr"] is False + def test_always_mode_enables_ssr(self): + """With ssr_mode=always, config has ssr: true.""" + conf = rx.Config(app_name="test", ssr_mode=SsrMode.ALWAYS) + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is True + class TestTemplateOutput: - """Tests for the generated template content based on runtime_ssr.""" + """Tests for the generated template content based on ssr_mode.""" @staticmethod - def _render_root(runtime_ssr: bool) -> str: + def _render_root(ssr_mode: SsrMode) -> str: """Render root template with minimal valid params. Args: - runtime_ssr: Whether runtime SSR is enabled. + ssr_mode: The server-side rendering mode. Returns: Rendered template string. @@ -125,12 +140,12 @@ def _render_root(runtime_ssr: bool) -> str: window_libraries=[], render={"contents": "children"}, dynamic_imports=set(), - runtime_ssr=runtime_ssr, + ssr_mode=ssr_mode, ) - 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) + def test_root_template_has_loader_when_ssr_enabled(self): + """With ssr_mode=bot_only, root.jsx template contains the SSR loader.""" + result = self._render_root(ssr_mode=SsrMode.BOT_ONLY) assert "export async function loader" in result assert "useLoaderData" in result @@ -140,24 +155,24 @@ def test_root_template_has_loader_when_ssr_true(self): 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) + result = self._render_root(ssr_mode=SsrMode.BOT_ONLY) 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) + def test_root_template_no_loader_when_ssr_off(self): + """With ssr_mode=off, root.jsx template has no SSR loader.""" + result = self._render_root(ssr_mode=SsrMode.OFF) 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.""" + def test_context_template_has_ssr_context_when_ssr_enabled(self): + """With ssr_mode=bot_only, context.js has SSRContext and ssrHydrated.""" from reflex.compiler.templates import context_template result = context_template( @@ -166,7 +181,7 @@ def test_context_template_has_ssr_context_when_ssr_true(self): client_storage=None, is_dev_mode=False, default_color_mode='"system"', - runtime_ssr=True, + ssr_mode=SsrMode.BOT_ONLY, ) assert "SSRContext" in result @@ -174,8 +189,8 @@ def test_context_template_has_ssr_context_when_ssr_true(self): 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.""" + def test_context_template_no_ssr_context_when_ssr_off(self): + """With ssr_mode=off, context.js has no SSR-related code.""" from reflex.compiler.templates import context_template result = context_template( @@ -184,7 +199,7 @@ def test_context_template_no_ssr_context_when_ssr_false(self): client_storage=None, is_dev_mode=False, default_color_mode='"system"', - runtime_ssr=False, + ssr_mode=SsrMode.OFF, ) assert "SSRContext" not in result @@ -192,7 +207,7 @@ def test_context_template_no_ssr_context_when_ssr_false(self): assert "ssrState" not in result def test_context_template_ssr_reducer_uses_ssr_state(self): - """With runtime_ssr=True, useReducer initializers check ssrState.""" + """With ssr_mode=bot_only, useReducer initializers check ssrState.""" from reflex.compiler.templates import context_template result = context_template( @@ -201,14 +216,14 @@ def test_context_template_ssr_reducer_uses_ssr_state(self): client_storage=None, is_dev_mode=False, default_color_mode='"system"', - runtime_ssr=True, + ssr_mode=SsrMode.BOT_ONLY, ) # 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.""" + """With ssr_mode=off, useReducer uses initialState directly.""" from reflex.compiler.templates import context_template result = context_template( @@ -217,7 +232,7 @@ def test_context_template_static_reducer_no_ssr_state(self): client_storage=None, is_dev_mode=False, default_color_mode='"system"', - runtime_ssr=False, + ssr_mode=SsrMode.OFF, ) assert 'useReducer(applyDelta, initialState["my_app.state"]' in result From 84111397e4fe842d4b97d21319e63f4ba1954fdb Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 18 Jul 2026 18:06:02 +0200 Subject: [PATCH 12/15] plugin based ssr --- pyi_hashes.json | 2 +- reflex/app.py | 160 +----------- reflex/compiler/compiler.py | 23 +- reflex/compiler/templates.py | 107 ++------ reflex/ssr.py | 419 ++++++++++++++++++++++++++++++ reflex/utils/build.py | 52 +--- reflex/utils/frontend_skeleton.py | 32 +-- tests/units/test_ssr_compile.py | 48 ++-- tests/units/test_ssr_data.py | 3 +- 9 files changed, 495 insertions(+), 351 deletions(-) create mode 100644 reflex/ssr.py diff --git a/pyi_hashes.json b/pyi_hashes.json index ec9bbff8850..0d730944b51 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,5 +1,5 @@ { - "reflex/__init__.pyi": "0a3ae880e256b9fd3b960e12a2cb51a7", + "reflex/__init__.pyi": "f7af497ff5c98e6a8974a987a498f865", "reflex/components/__init__.pyi": "ac05995852baa81062ba3d18fbc489fb", "reflex/components/base/__init__.pyi": "16e47bf19e0d62835a605baa3d039c5a", "reflex/components/base/app_wrap.pyi": "22e94feaa9fe675bcae51c412f5b67f1", diff --git a/reflex/app.py b/reflex/app.py index a44c129b906..a5656e83eef 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -43,7 +43,7 @@ from starlette.responses import JSONResponse, Response, StreamingResponse from starlette.staticfiles import StaticFiles -from reflex import constants +from reflex import constants, ssr from reflex.admin import AdminDash from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.compiler import compiler @@ -86,7 +86,6 @@ 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, @@ -690,10 +689,10 @@ def _add_default_endpoints(self): health, methods=["GET"], ) - if get_config().ssr_mode != constants.SsrMode.OFF: + if ssr.is_enabled(): self._api.add_route( str(constants.Endpoint.SSR_DATA), - ssr_data(self), + ssr.ssr_data(self), methods=["POST"], ) @@ -1468,9 +1467,8 @@ def _submit_work_without_advancing( progress.advance(task) # Compile the contexts. - ssr_mode = get_config().ssr_mode compile_results.append( - compiler.compile_contexts(self._state, self.theme, ssr_mode=ssr_mode), + compiler.compile_contexts(self._state, self.theme), ) if self.theme is not None: # Fix #2992 by removing the top-level appearance prop @@ -1479,7 +1477,7 @@ def _submit_work_without_advancing( # Compile the app root. compile_results.append( - compiler.compile_app(app_root, ssr_mode=ssr_mode), + compiler.compile_app(app_root), ) progress.advance(task) @@ -1499,8 +1497,8 @@ def _submit_work_without_advancing( frontend_skeleton.initialize_package_json() # Copy SSR scripts when SSR is enabled. - if ssr_mode != constants.SsrMode.OFF: - frontend_skeleton.copy_ssr_scripts() + if ssr.is_enabled(): + ssr.copy_scripts(prerequisites.get_web_dir()) if is_prod_mode(): # Empty the .web pages directory. @@ -1819,21 +1817,6 @@ 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 @@ -1919,135 +1902,6 @@ 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) # pyright: ignore[reportCallIssue] - - # 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 - - class _UploadStreamingResponse(StreamingResponse): """Streaming response that always releases upload form resources.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index a838ec8c177..a02fea9509c 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -73,15 +73,11 @@ def _normalize_library_name(lib: str) -> str: return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") -def _compile_app( - app_root: Component, - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, -) -> str: +def _compile_app(app_root: Component) -> str: """Compile the app template component. Args: app_root: The app root to compile. - ssr_mode: The server-side rendering mode. Returns: The compiled app. @@ -104,7 +100,6 @@ def _compile_app( window_libraries=window_libraries_deduped, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), - ssr_mode=ssr_mode, ) @@ -123,14 +118,12 @@ def _compile_theme(theme: str) -> str: def _compile_contexts( state: type[BaseState] | None, theme: Component | None, - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ) -> str: """Compile the initial state and contexts. Args: state: The app state. theme: The top-level app theme. - ssr_mode: The server-side rendering mode. Returns: The compiled context file. @@ -146,13 +139,11 @@ def _compile_contexts( client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), - ssr_mode=ssr_mode, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), - ssr_mode=ssr_mode, ) ) @@ -503,15 +494,11 @@ def compile_document_root( return output_path, code -def compile_app( - app_root: Component, - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, -) -> tuple[str, str]: +def compile_app(app_root: Component) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. - ssr_mode: The server-side rendering mode. Returns: The path and code of the compiled app wrapper. @@ -522,7 +509,7 @@ def compile_app( ) # Compile the document root. - code = _compile_app(app_root, ssr_mode=ssr_mode) + code = _compile_app(app_root) return output_path, code @@ -548,14 +535,12 @@ def compile_theme(style: ComponentStyle) -> tuple[str, str]: def compile_contexts( state: type[BaseState] | None, theme: Component | None, - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ) -> tuple[str, str]: """Compile the initial state / context. Args: state: The app state. theme: The top-level app theme. - ssr_mode: The server-side rendering mode. Returns: The path and code of the compiled context. @@ -563,7 +548,7 @@ def compile_contexts( # Get the path for the output file. output_path = utils.get_context_path() - return output_path, _compile_contexts(state, theme, ssr_mode=ssr_mode) + return output_path, _compile_contexts(state, theme) def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index e087cd4e755..cdce9a99042 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -170,7 +170,6 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ): """Template for the App root. @@ -181,11 +180,11 @@ def app_root_template( window_libraries: The list of window libraries. render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. - ssr_mode: The server-side rendering mode. Returns: Rendered App root component as string. """ + from reflex import ssr imports_str = "\n".join([_RenderUtils.get_import(mod) for mod in imports]) dynamic_imports_str = "\n".join(dynamic_imports) @@ -200,55 +199,11 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) - if ssr_mode != constants.SsrMode.OFF: - 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 = "{}" + snippets = ssr.app_root_snippets(ssr.is_enabled()) + ssr_imports = snippets["imports"] + ssr_loader = snippets["loader"] + ssr_layout_head = snippets["layout_head"] + ssr_state_provider_props = snippets["state_provider_props"] return f""" {imports_str} @@ -314,7 +269,6 @@ 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, - ssr_mode: constants.SsrMode = constants.SsrMode.OFF, ): """Template for the context file. @@ -324,11 +278,12 @@ 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. - ssr_mode: The server-side rendering mode. Returns: Rendered context file content as string. """ + from reflex import ssr + initial_state = initial_state or {} state_contexts_str = "".join([ f"{format_state_name(state_name)}: createContext(null)," @@ -382,17 +337,12 @@ def context_template( """ ) - ssr_enabled = ssr_mode != constants.SsrMode.OFF - if ssr_enabled: - 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 - ) + ssr_enabled = ssr.is_enabled() + ssr_snippets = ssr.context_snippets(ssr_enabled) + state_reducer_str = "\n".join( + rf"const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, {ssr.state_reducer_initial(state_name, ssr_enabled)})" + 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)}}}," @@ -415,12 +365,7 @@ 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 SSRContext = createContext(false);''' - if ssr_enabled - else "" - } +export const EventLoopContext = createContext(null);{ssr_snippets["ssr_context_export"]} export const clientStorage = { "{}" if client_storage is None else json.dumps(client_storage) } @@ -458,21 +403,11 @@ def context_template( }} export function EventLoopProvider({{ children }}) {{ - const dispatch = useContext(DispatchContext){ - ''' - const ssrHydrated = useContext(SSRContext)''' - if ssr_enabled - else "" - } + const dispatch = useContext(DispatchContext){ssr_snippets["event_loop_ssr_hook"]} const [addEvents, connectErrors] = useEventLoop( dispatch, initialEvents, - clientStorage,{ - ''' - ssrHydrated,''' - if ssr_enabled - else "" - } + clientStorage,{ssr_snippets["event_loop_ssr_arg"]} ) return createElement( EventLoopContext.Provider, @@ -481,9 +416,7 @@ def context_template( ); }} -export function StateProvider({{ children{ - ", ssrState = null" if ssr_enabled else "" - } }}) {{ +export function StateProvider({{ children{ssr_snippets["state_provider_arg"]} }}) {{ {state_reducer_str} const dispatchers = useMemo(() => {{ return {{ @@ -492,11 +425,11 @@ def context_template( }}, []) return ( - {"createElement(SSRContext.Provider, {value: !!ssrState}," if ssr_enabled else ""} + {ssr_snippets["state_provider_open"]} {create_state_contexts_str} createElement(DispatchContext, {{value: dispatchers}}, children) {")" * len(initial_state)} - {")" if ssr_enabled else ""} + {ssr_snippets["state_provider_close"]} ) }}""" diff --git a/reflex/ssr.py b/reflex/ssr.py new file mode 100644 index 00000000000..3041c50fae1 --- /dev/null +++ b/reflex/ssr.py @@ -0,0 +1,419 @@ +"""Server-side rendering (SSR) support for Reflex apps. + +This module is the single home for everything SSR-specific so the rest of the +codebase stays SSR-agnostic. It covers three concerns: + +* Backend: the ``/_ssr_data`` endpoint that computes the hydrated initial state + for a route (reusing the regular event machinery). +* Compiler: the JS snippets injected into the ``app`` root and ``context`` + templates when SSR is enabled. +* Build/serve: dependencies, scripts, and packaging tweaks required to run a + react-router SSR server in production. + +Everything here is a no-op unless ``config.ssr_mode`` is not ``OFF``. +""" + +from __future__ import annotations + +import traceback +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from reflex import constants +from reflex.config import get_config +from reflex.utils import console, path_ops, processes + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + + from reflex.app import App + from reflex.config import Config + from reflex.event import Event, EventHandler, EventSpec + from reflex.state import BaseState + +# Sentinel token/session id used for the stateless SSR render. +SSR_TOKEN = "__ssr__" + + +def is_enabled(config: Config | None = None) -> bool: + """Whether SSR is enabled. + + Args: + config: The config to read from, or the global config when omitted. + + Returns: + True when ``config.ssr_mode`` is not ``OFF``. + """ + config = config if config is not None else get_config() + return config.ssr_mode != constants.SsrMode.OFF + + +# --------------------------------------------------------------------------- +# Backend: the /_ssr_data endpoint. +# --------------------------------------------------------------------------- + + +def _build_router_data( + app: App, path: str, headers: dict[str, str], client_ip: str +) -> dict[str, Any]: + """Build the ``router_data`` dict for a stateless SSR render. + + Args: + app: The app, used to resolve the concrete path to a route pattern. + path: The concrete request path (e.g. ``/blog/hello-world``). + headers: The forwarded request headers. + client_ip: The client IP address. + + Returns: + A ``router_data`` dict with the same shape ``process()`` produces. + """ + from reflex.route import extract_route_params + + resolved_route = app.router(path) or "404" + params = extract_route_params(path, resolved_route) + return { + constants.RouteVar.PATH: "/" + resolved_route.removeprefix("/"), + constants.RouteVar.ORIGIN: path, + constants.RouteVar.QUERY: dict(params), + constants.RouteVar.CLIENT_TOKEN: SSR_TOKEN, + constants.RouteVar.SESSION_ID: SSR_TOKEN, + constants.RouteVar.HEADERS: { + "origin": headers.get("origin", headers.get("host", "http://localhost")), + **headers, + }, + constants.RouteVar.CLIENT_IP: client_ip, + } + + +async def _process_ssr_event(state: BaseState, event: Event, path: str) -> None: + """Run a single on_load event on the SSR state, swallowing errors. + + Handlers mutate the state instance in place; the yielded deltas are + irrelevant since the whole tree is serialized afterwards. + + Args: + state: The ephemeral root state instance. + event: The event to process. + path: The URL path (for error logging). + """ + try: + async for _update in state._process(event): + pass + except Exception: + console.warn(f"SSR on_load handler failed for {path}: {traceback.format_exc()}") + + +async def _run_on_load_events(app: App, state: BaseState, path: str) -> None: + """Run the route's on_load handlers on the ephemeral SSR state. + + Reuses the regular per-event executor (``state._process``) so on_load + handlers run through the exact same code path as the websocket flow, + correctly handling sync/async handlers, generators, and event chains. + + Args: + app: The app to get load events from. + state: The ephemeral root state instance. + path: The URL path (for error logging). + """ + from reflex.event import fix_events + + load_events = app.get_load_events(path) + if not load_events: + return + + events = fix_events( + cast("list[EventSpec | EventHandler]", load_events), + SSR_TOKEN, + router_data=state.router_data, + ) + for event in events: + await _process_ssr_event(state, event, path) + + +def ssr_data(app: App): + """Build the ``/_ssr_data`` endpoint handler. + + The handler creates an ephemeral state, applies route data, runs on_load + handlers, and returns the serialized state tree for server-side rendering. + + Args: + app: The app to get SSR data for. + + Returns: + The SSR data request handler. + """ + from starlette.responses import Response + + from reflex.state import RouterData, State + from reflex.utils import format + + 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", + ) + + # Ephemeral root state — no persistent session is created. Use State + # (root) rather than app._state which may be a subclass whose inherited + # vars can't be set without a parent. + state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + + router_data = _build_router_data( + app, + path, + headers, + request.client.host if request.client else "0.0.0.0", + ) + # Assigning router_data recomputes dependent DynamicRouteVars. + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + + await _run_on_load_events(app, state, path) + + json_str = format.json_dumps({"state": state.dict()}) + return Response( + content=json_str, + media_type="application/json", + headers={"Cache-Control": "no-cache"}, + ) + + return ssr_data_handler + + +# --------------------------------------------------------------------------- +# Compiler: JS snippets injected into the app root and context templates. +# --------------------------------------------------------------------------- + +_APP_ROOT_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 }; +} +""" + + +def app_root_snippets(enabled: bool) -> dict[str, str]: + """SSR snippets for the app root template. + + Args: + enabled: Whether SSR is enabled. + + Returns: + A dict of snippet strings keyed by their template placeholder. + """ + if not enabled: + return { + "imports": 'import { Outlet } from "react-router";', + "loader": "", + "layout_head": "", + "state_provider_props": "{}", + } + return { + "imports": ( + 'import { Outlet, useLoaderData } from "react-router";\n' + 'import { getBackendURL } from "$/utils/state";\n' + 'import env from "$/env.json";' + ), + "loader": _APP_ROOT_LOADER, + "layout_head": ( + " const loaderData = useLoaderData();\n" + " const ssrState = loaderData?.state || null;\n" + ), + "state_provider_props": "{ssrState}", + } + + +def context_snippets(enabled: bool) -> dict[str, str]: + """SSR snippets for the context template. + + Args: + enabled: Whether SSR is enabled. + + Returns: + A dict of snippet strings keyed by their template placeholder. + """ + if not enabled: + return { + "ssr_context_export": "", + "event_loop_ssr_hook": "", + "event_loop_ssr_arg": "", + "state_provider_arg": "", + "state_provider_open": "", + "state_provider_close": "", + } + return { + "ssr_context_export": "\nexport const SSRContext = createContext(false);", + "event_loop_ssr_hook": "\n const ssrHydrated = useContext(SSRContext)", + "event_loop_ssr_arg": "\n ssrHydrated,", + "state_provider_arg": ", ssrState = null", + "state_provider_open": "createElement(SSRContext.Provider, {value: !!ssrState},", + "state_provider_close": ")", + } + + +def state_reducer_initial(state_name: str, enabled: bool) -> str: + """The reducer initial value expression for a state slice. + + Args: + state_name: The state slice name. + enabled: Whether SSR is enabled. + + Returns: + A JS expression for the ``useReducer`` initial value. + """ + if enabled: + return ( + f'ssrState !== null && ssrState["{state_name}"] != null ' + f'? ssrState["{state_name}"] : initialState["{state_name}"]' + ) + return f'initialState["{state_name}"]' + + +# --------------------------------------------------------------------------- +# Build / serve helpers. +# --------------------------------------------------------------------------- + + +def react_router_ssr(config: Config | None = None) -> bool: + """Value for the ``ssr`` field of react-router.config.js. + + Args: + config: The config to read from, or the global config when omitted. + + Returns: + True when SSR is enabled. + """ + return is_enabled(config) + + +def prod_command(config: Config | None = None) -> str | None: + """The production ``prod`` npm script command when SSR is enabled. + + Args: + config: The config to read from, or the global config when omitted. + + Returns: + The SSR prod command, or None to use the default static server. + """ + if not is_enabled(config): + return None + return constants.PackageJson.Commands.PROD_SSR + + +def extra_dependencies(config: Config | None = None) -> dict[str, str]: + """Additional npm dependencies required when SSR is enabled. + + Args: + config: The config to read from, or the global config when omitted. + + Returns: + A dict of dependency name to version (empty when SSR is off). + """ + if not is_enabled(config): + return {} + return dict(constants.PackageJson.SSR_DEPENDENCIES) + + +# Scripts copied verbatim from the web template into the .web root when SSR is on. +_SSR_SCRIPTS = ("ssr-serve.js", "generate-shell.mjs") + +# Source directories that live only in the .web root (not build/) and must be +# excluded when zipping an SSR frontend for deployment. +_ZIP_EXCLUDE_DIRS = frozenset( + {"node_modules", "app", "utils", "styles", "components", "backend", "public"} +) + + +def zip_exclude_dirs() -> frozenset[str]: + """Directory names to exclude when zipping an SSR frontend. + + Returns: + The set of directory names to exclude. + """ + return _ZIP_EXCLUDE_DIRS + + +def copy_scripts(web_dir: Path) -> None: + """Copy the SSR runtime scripts into the .web directory. + + Args: + web_dir: The .web directory to copy the scripts into. + """ + import shutil + + for filename in _SSR_SCRIPTS: + src = constants.Templates.Dirs.WEB_TEMPLATE / filename + if src.exists(): + shutil.copy2(str(src), str(web_dir / filename)) + + +def generate_shell(web_dir: Path) -> None: + """Generate the static SPA shell (build/client/index.html) after an SSR build. + + Runs generate-shell.mjs, which renders the app with a normal user-agent so + the loader returns empty state, and writes the resulting HTML. The prod + server serves this file to non-bot users for zero SSR overhead. + + Args: + web_dir: The .web directory. + """ + shell_script = web_dir / "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=web_dir, + 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.") diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 61008dd8818..ba551e4d254 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -8,7 +8,7 @@ from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn -from reflex import constants +from reflex import constants, ssr from reflex.config import get_config from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes from reflex.utils.exec import is_in_app_harness @@ -142,24 +142,16 @@ def zip_app( if frontend: web_dir = prerequisites.get_web_dir() - if get_config().ssr_mode != constants.SsrMode.OFF: + if ssr.is_enabled(): # 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. + # Use the web dir as root and exclude the source-only directories. _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", - }, + directory_names_to_exclude=set(ssr.zip_exclude_dirs()), ) else: _zip( @@ -208,38 +200,6 @@ 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. @@ -281,8 +241,8 @@ def build(): raise SystemExit(1) # When SSR is enabled, generate a static SPA shell for non-bot users. - if get_config().ssr_mode != constants.SsrMode.OFF: - _generate_ssr_shell(wdir) + if ssr.is_enabled(): + ssr.generate_shell(wdir) _duplicate_index_html_to_parent_directory(wdir / constants.Dirs.STATIC) diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 4a6a0a23ec1..cabefa37a44 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -5,7 +5,7 @@ import re from pathlib import Path -from reflex import constants +from reflex import constants, ssr from reflex.compiler import templates from reflex.config import Config, get_config from reflex.environment import environment @@ -147,21 +147,6 @@ 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 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("/"): @@ -172,7 +157,7 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): "future": { "unstable_optimizeDeps": True, }, - "ssr": config.ssr_mode != constants.SsrMode.OFF, + "ssr": ssr.react_router_ssr(config), } if prerender_routes: @@ -184,15 +169,10 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): def _compile_package_json(): config = get_config() - ssr_enabled = config.ssr_mode != constants.SsrMode.OFF - prod_command = ( - constants.PackageJson.Commands.PROD_SSR - if ssr_enabled - else constants.PackageJson.Commands.get_prod_command(config.frontend_path) - ) - deps = dict(constants.PackageJson.DEPENDENCIES) - if ssr_enabled: - deps.update(constants.PackageJson.SSR_DEPENDENCIES) + prod_command = ssr.prod_command( + config + ) or constants.PackageJson.Commands.get_prod_command(config.frontend_path) + deps = {**constants.PackageJson.DEPENDENCIES, **ssr.extra_dependencies(config)} return templates.package_json_template( scripts={ "dev": constants.PackageJson.Commands.DEV, diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py index 1b52e1d280e..f2df1d48501 100644 --- a/tests/units/test_ssr_compile.py +++ b/tests/units/test_ssr_compile.py @@ -122,11 +122,19 @@ class TestTemplateOutput: """Tests for the generated template content based on ssr_mode.""" @staticmethod - def _render_root(ssr_mode: SsrMode) -> str: - """Render root template with minimal valid params. + def _set_ssr_mode(mocker: MockerFixture, ssr_mode: SsrMode) -> None: + """Patch the config the ssr module reads so templates see ``ssr_mode``. Args: - ssr_mode: The server-side rendering mode. + mocker: The pytest-mock fixture. + ssr_mode: The server-side rendering mode to configure. + """ + conf = rx.Config(app_name="test", ssr_mode=ssr_mode) + mocker.patch("reflex.ssr.get_config", return_value=conf) + + @staticmethod + def _render_root() -> str: + """Render root template with minimal valid params. Returns: Rendered template string. @@ -140,12 +148,12 @@ def _render_root(ssr_mode: SsrMode) -> str: window_libraries=[], render={"contents": "children"}, dynamic_imports=set(), - ssr_mode=ssr_mode, ) - def test_root_template_has_loader_when_ssr_enabled(self): + def test_root_template_has_loader_when_ssr_enabled(self, mocker: MockerFixture): """With ssr_mode=bot_only, root.jsx template contains the SSR loader.""" - result = self._render_root(ssr_mode=SsrMode.BOT_ONLY) + self._set_ssr_mode(mocker, SsrMode.BOT_ONLY) + result = self._render_root() assert "export async function loader" in result assert "useLoaderData" in result @@ -153,35 +161,39 @@ def test_root_template_has_loader_when_ssr_enabled(self): assert "ssrState" in result assert "SSR_DATA" in result - def test_root_template_loader_checks_shell_gen_header(self): + def test_root_template_loader_checks_shell_gen_header(self, mocker: MockerFixture): """The SSR loader short-circuits for shell generation requests.""" - result = self._render_root(ssr_mode=SsrMode.BOT_ONLY) + self._set_ssr_mode(mocker, SsrMode.BOT_ONLY) + result = self._render_root() 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_off(self): + def test_root_template_no_loader_when_ssr_off(self, mocker: MockerFixture): """With ssr_mode=off, root.jsx template has no SSR loader.""" - result = self._render_root(ssr_mode=SsrMode.OFF) + self._set_ssr_mode(mocker, SsrMode.OFF) + result = self._render_root() 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_enabled(self): + def test_context_template_has_ssr_context_when_ssr_enabled( + self, mocker: MockerFixture + ): """With ssr_mode=bot_only, context.js has SSRContext and ssrHydrated.""" from reflex.compiler.templates import context_template + self._set_ssr_mode(mocker, SsrMode.BOT_ONLY) 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"', - ssr_mode=SsrMode.BOT_ONLY, ) assert "SSRContext" in result @@ -189,50 +201,50 @@ def test_context_template_has_ssr_context_when_ssr_enabled(self): assert "ssrState = null" in result assert "SSRContext.Provider" in result - def test_context_template_no_ssr_context_when_ssr_off(self): + def test_context_template_no_ssr_context_when_ssr_off(self, mocker: MockerFixture): """With ssr_mode=off, context.js has no SSR-related code.""" from reflex.compiler.templates import context_template + self._set_ssr_mode(mocker, SsrMode.OFF) 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"', - ssr_mode=SsrMode.OFF, ) 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): + def test_context_template_ssr_reducer_uses_ssr_state(self, mocker: MockerFixture): """With ssr_mode=bot_only, useReducer initializers check ssrState.""" from reflex.compiler.templates import context_template + self._set_ssr_mode(mocker, SsrMode.BOT_ONLY) 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"', - ssr_mode=SsrMode.BOT_ONLY, ) # 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): + def test_context_template_static_reducer_no_ssr_state(self, mocker: MockerFixture): """With ssr_mode=off, useReducer uses initialState directly.""" from reflex.compiler.templates import context_template + self._set_ssr_mode(mocker, SsrMode.OFF) 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"', - ssr_mode=SsrMode.OFF, ) assert 'useReducer(applyDelta, initialState["my_app.state"]' in result diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py index 1d4788b2a78..b955aeef05f 100644 --- a/tests/units/test_ssr_data.py +++ b/tests/units/test_ssr_data.py @@ -10,7 +10,8 @@ from starlette.responses import Response import reflex as rx -from reflex.app import App, ssr_data +from reflex.app import App +from reflex.ssr import ssr_data from reflex.state import State, all_base_state_classes From ec14b542fee873a9a7c1c206330e04043bace608 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 19 Jul 2026 00:34:36 +0200 Subject: [PATCH 13/15] isr wip --- .../reflex_base/.templates/web/isr-render.mjs | 109 +++ .../src/reflex_base/compiler/templates.py | 68 +- .../reflex-base/src/reflex_base/config.py | 14 + packages/reflex-base/src/reflex_base/ssr.py | 14 + reflex/isr.py | 836 ++++++++++++++++++ reflex/ssr.py | 34 +- reflex/testing.py | 1 - reflex/utils/frontend_skeleton.py | 4 +- tests/units/test_isr.py | 194 ++++ tests/units/test_isr_app.py | 138 +++ 10 files changed, 1389 insertions(+), 23 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs create mode 100644 reflex/isr.py create mode 100644 tests/units/test_isr.py create mode 100644 tests/units/test_isr_app.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs b/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs new file mode 100644 index 00000000000..c4edace1a51 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs @@ -0,0 +1,109 @@ +/** + * ISR render tier for Reflex. + * + * A small HTTP service that renders the react-router **server bundle** to HTML + * for a given route, so the Python ISR page-server (reflex.isr.HttpRenderer) + * can cache and serve it. This is the one component that must run JavaScript; + * it is deployed as its own service (off the request hot path, scale-to-zero + * friendly) rather than in every frontend pod. + * + * Contract (matches reflex.isr.HttpRenderer): + * POST / { "path": "/blog/hello" } + * 200 -> { "html": "…", "revalidate"?: number, "tags"?: string[] } + * non-200 -> ISR treats it as a render failure (serves stale/shell) + * + * Requirements: + * - An SSR build of the app (react-router build with `ssr: true`) producing + * `build/server/index.js`. The app root `loader()` fetches page state from + * the Python `/_ssr_data` endpoint, so this service needs network access to + * the backend (set SSR_DATA / the backend URL in the app env). + * - Node deps: `react-router`, `express`. + * + * Run: node isr-render.mjs (listens on $PORT, default 8600) + * + * Optional per-page cache hints: the app may set response headers + * `X-Reflex-Revalidate: ` and `X-Reflex-Tags: a,b,c` + * which are forwarded to the ISR cache; otherwise Python applies its defaults. + */ +import { createRequestHandler } from "react-router"; +import express from "express"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = + import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); + +const serverBuild = join(__dirname, "build", "server", "index.js"); +const build = await import(serverBuild); +const handler = createRequestHandler(build, "production"); + +const app = express(); +app.use(express.json({ limit: "1mb" })); + +// Health endpoint for k8s probes. +app.get("/_health", (_req, res) => res.status(200).send("OK")); + +app.post("/", async (req, res) => { + const path = + typeof req.body?.path === "string" && req.body.path ? req.body.path : "/"; + + try { + // Render the target route through react-router as a normal GET document + // request. The app's loader runs here and pulls state from /_ssr_data. + const url = new URL(path, "http://reflex-isr-render.local"); + const request = new Request(url, { + method: "GET", + headers: { + "User-Agent": "ReflexISRRenderer/1.0", + Accept: "text/html", + // Forward the caller's cookies so authenticated/personalized loaders + // resolve the same way they would for the original visitor, if the + // page-server chooses to pass them along. + ...(req.body?.cookie ? { Cookie: String(req.body.cookie) } : {}), + }, + }); + + const response = await handler(request); + const html = await response.text(); + + if (!response.ok || !html) { + return res + .status(502) + .json({ error: "render_failed", status: response.status }); + } + + const revalidate = headerNumber(response, "x-reflex-revalidate"); + const tags = headerList(response, "x-reflex-tags"); + + return res.json({ + html, + ...(revalidate != null ? { revalidate } : {}), + ...(tags.length ? { tags } : {}), + }); + } catch (err) { + console.error("[isr-render] render error for", path, err); + return res.status(500).json({ error: String(err) }); + } +}); + +function headerNumber(response, name) { + const raw = response.headers.get(name); + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +function headerList(response, name) { + const raw = response.headers.get(name); + return raw + ? raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; +} + +const port = parseInt(process.env.PORT || "8600", 10); +app.listen(port, () => { + console.log(`[isr-render] listening on http://localhost:${port}`); +}); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index ca94aa2c482..e358e8da467 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -208,6 +208,50 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) + from reflex_base import ssr + + if ssr.is_enabled(): + ssr_imports = ( + 'import { useLoaderData } from "react-router";\n' + 'import { getBackendURL } from "$/utils/state";\n' + 'import { SSRStateContext } from "$/utils/context";\n' + 'import env from "$/env.json";' + ) + ssr_loader = """ +export async function loader({ request }) { + // Fetch on_load-hydrated state from the Python /_ssr_data endpoint so the + // server-rendered HTML already contains real data. + try { + const res = await fetch(getBackendURL(env.SSR_DATA), { + 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 }; +} +""" + layout_body = ( + "const loaderData = useLoaderData();\n" + " const ssrState = loaderData?.state ?? null;\n" + " return jsx(AppLayout, {}, " + "jsx(SSRStateContext.Provider, {value: ssrState}, " + "jsx(ReflexProviders, {}, children)));" + ) + else: + ssr_imports = "" + ssr_loader = "" + layout_body = "return jsx(AppLayout, {}, jsx(ReflexProviders, {}, children));" + return f""" {imports_str} {dynamic_imports_str} @@ -215,10 +259,11 @@ def app_root_template( 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 ReflexProviders({{children}}) {{ useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components @@ -240,7 +285,7 @@ def app_root_template( }} export function Layout({{children}}) {{ - return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children)); + {layout_body} }} // Used by entry.client.js when mount_target is configured: skips the document @@ -289,6 +334,9 @@ def context_template( Returns: Rendered context file content as string. """ + from reflex_base import ssr + + ssr_enabled = ssr.is_enabled() initial_state = initial_state or {} state_contexts_str = "".join([ f"{format_state_name(state_name)}: createContext(null)," @@ -342,10 +390,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 ssr_enabled: + state_reducer_str = "\n".join( + rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, (ssrState && 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)}}}," @@ -374,6 +428,7 @@ def context_template( export const DispatchContext = createContext(null); export const StateContexts = {{{state_contexts_str}}}; export const EventLoopContext = createContext(null); +{"export const SSRStateContext = createContext(null);" if ssr_enabled else ""} export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} {state_str} @@ -446,6 +501,7 @@ def context_template( }} export function StateProvider({{ children }}) {{ + {"const ssrState = useContext(SSRStateContext);" if ssr_enabled else ""} {state_reducer_str} const dispatchers = useMemo(() => {{ return {{ diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index b6fe448c717..57a70f99d20 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -187,6 +187,9 @@ class BaseConfig: disable_plugins: List of plugin types to disable in the app. transport: The transport method for client-server communication. ssr_mode: Server-side rendering mode ("off", "bot_only", or "always"). + isr_render_url: ISR render-tier URL; when set, enables ISR page serving. + isr_revalidate: Default seconds until an ISR-cached page goes stale. + isr_build_id: Identifier that rotates the ISR cache on each deploy. """ app_name: str @@ -275,6 +278,17 @@ class BaseConfig: # Server-side rendering mode: "off" (default), "bot_only", or "always". ssr_mode: constants.SsrMode = constants.SsrMode.OFF + # ISR render-tier URL. When set, the ISR page-server regenerates pages by + # POSTing paths here; when None, ISR is disabled. + isr_render_url: str | None = None + + # Default seconds until an ISR-cached page is considered stale. + isr_revalidate: int = 60 + + # Identifier that rotates the ISR cache on each deploy. Defaults at runtime + # to the REFLEX_BUILD_ID env var (see reflex.isr.get_build_id). + isr_build_id: str | None = None + # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/packages/reflex-base/src/reflex_base/ssr.py b/packages/reflex-base/src/reflex_base/ssr.py index f642e37695b..6875c3b8c0b 100644 --- a/packages/reflex-base/src/reflex_base/ssr.py +++ b/packages/reflex-base/src/reflex_base/ssr.py @@ -10,6 +10,7 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING from reflex_base import constants @@ -30,3 +31,16 @@ def is_enabled(config: Config | None = None) -> bool: """ config = config if config is not None else get_config() return config.ssr_mode != constants.SsrMode.OFF + + +def ssr_build_enabled() -> bool: + """Whether to emit an ``ssr: true`` react-router build. + + Controlled by the ``REFLEX_SSR_BUILD`` env var so the same app can produce + both the default static SPA build (served by nginx) and a server-render + build (consumed by the ISR render tier) from separate build invocations. + + Returns: + True when ``REFLEX_SSR_BUILD`` is set to ``1``. + """ + return os.environ.get("REFLEX_SSR_BUILD") == "1" diff --git a/reflex/isr.py b/reflex/isr.py new file mode 100644 index 00000000000..28db1a9003c --- /dev/null +++ b/reflex/isr.py @@ -0,0 +1,836 @@ +"""Incremental Static Regeneration (ISR) for Reflex. + +Serves prerendered HTML from a shared cache with stale-while-revalidate (SWR) +semantics and cross-worker single-flight regeneration, so multiple workers +(e.g. Kubernetes replicas) share one cache and never double-render a page. + +Responsibilities split: + +* This module owns **caching, coordination, and revalidation** — pure Python, + no JS runtime. It works across workers when backed by Redis. +* Producing the HTML is delegated to a pluggable :data:`Renderer` (e.g. a Node + render tier that runs the react-router server bundle and fetches state from + the ``/_ssr_data`` endpoint). ISR itself never renders React. + +Cache entries are keyed by ``{build_id}:{path}`` so a new deploy (new build id) +transparently rotates the cache and never serves HTML that references +content-hashed assets from a previous build. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import json +import os +import time +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from reflex.utils import console + +if TYPE_CHECKING: + import httpx + from redis.asyncio import Redis + from starlette.applications import Starlette + + from reflex.config import Config + + +@dataclasses.dataclass(frozen=True) +class RenderResult: + """The result of rendering a page. + + Attributes: + html: The rendered HTML document. + revalidate: Seconds until the entry goes stale (``None`` = use default). + tags: Revalidation tags for on-demand invalidation of related pages. + """ + + html: str + revalidate: float | None = None + tags: tuple[str, ...] = () + + +# A Renderer turns a concrete request path into a RenderResult, or None on +# failure (in which case ISR serves stale/fallback rather than caching an error). +Renderer = Callable[[str], Awaitable["RenderResult | None"]] + + +@dataclasses.dataclass(frozen=True) +class CachedPage: + """A cached rendered page. + + Attributes: + html: The rendered HTML document. + generated_at: Unix timestamp when the page was rendered. + revalidate: Seconds until the page is considered stale (``<= 0`` never). + tags: Revalidation tags associated with the page. + """ + + html: str + generated_at: float + revalidate: float + tags: tuple[str, ...] = () + + def is_stale(self, now: float | None = None) -> bool: + """Whether the page is past its revalidation window. + + Args: + now: The current time (defaults to ``time.time()``). + + Returns: + True if the page should be revalidated. + """ + if self.revalidate <= 0: + return False + now = time.time() if now is None else now + return (now - self.generated_at) >= self.revalidate + + def to_json(self) -> str: + """Serialize the page for storage. + + Returns: + A JSON string. + """ + return json.dumps(dataclasses.asdict(self)) + + @classmethod + def from_json(cls, raw: str | bytes) -> CachedPage: + """Deserialize a page from storage. + + Args: + raw: The JSON string/bytes produced by :meth:`to_json`. + + Returns: + The reconstructed page. + """ + data = json.loads(raw) + return cls( + html=data["html"], + generated_at=data["generated_at"], + revalidate=data["revalidate"], + tags=tuple(data.get("tags", ())), + ) + + +@runtime_checkable +class ISRCache(Protocol): + """Shared cache + coordination backend for ISR. + + A Redis-backed implementation makes the cache, single-flight lock, and tag + index shared across all workers; the in-memory implementation is per-process + (dev/test only). + """ + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None if absent. + """ + ... + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + Args: + key: The cache key. + page: The page to store. + """ + ... + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + ... + + async def acquire_lock(self, key: str, ttl: float) -> bool: + """Try to acquire the single-flight render lock for ``key``. + + Args: + key: The cache key. + ttl: Lock expiry in seconds. + + Returns: + True if this caller now holds the lock. + """ + ... + + async def release_lock(self, key: str) -> None: + """Release the single-flight render lock for ``key``. + + Args: + key: The cache key. + """ + ... + + async def invalidate_tag(self, tag: str) -> int: + """Delete every cached page carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + ... + + +class MemoryISRCache: + """In-process ISR cache (single worker; dev/test only). + + Not shared across workers — use :class:`RedisISRCache` in production. + """ + + def __init__(self) -> None: + """Initialize empty stores.""" + self._pages: dict[str, CachedPage] = {} + self._tags: dict[str, set[str]] = {} + self._locks: set[str] = set() + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None. + """ + return self._pages.get(key) + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + Args: + key: The cache key. + page: The page to store. + """ + self._pages[key] = page + for tag in page.tags: + self._tags.setdefault(tag, set()).add(key) + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + self._pages.pop(key, None) + + async def acquire_lock(self, key: str, ttl: float) -> bool: + """Acquire the render lock (no TTL needed in-process). + + Args: + key: The cache key. + ttl: Unused for the in-memory backend. + + Returns: + True if the lock was acquired. + """ + if key in self._locks: + return False + self._locks.add(key) + return True + + async def release_lock(self, key: str) -> None: + """Release the render lock. + + Args: + key: The cache key. + """ + self._locks.discard(key) + + async def invalidate_tag(self, tag: str) -> int: + """Delete all pages carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + keys = self._tags.pop(tag, set()) + for key in keys: + self._pages.pop(key, None) + return len(keys) + + +class RedisISRCache: + """Redis-backed ISR cache shared across all workers.""" + + _PAGE_PREFIX = "reflex_isr:page:" + _LOCK_PREFIX = "reflex_isr:lock:" + _TAG_PREFIX = "reflex_isr:tag:" + + def __init__(self, redis: Redis) -> None: + """Store the async Redis client. + + Args: + redis: An async Redis client. + """ + self._redis = redis + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None. + """ + raw = await self._redis.get(self._PAGE_PREFIX + key) + return CachedPage.from_json(raw) if raw is not None else None + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + The stored value gets a generous TTL (well past ``revalidate``) so stale + entries remain available for stale-while-revalidate but do not leak + forever. + + Args: + key: The cache key. + page: The page to store. + """ + # Keep stale entries around for SWR: TTL = revalidate window + 24h grace. + ttl = int(page.revalidate) + 86400 if page.revalidate > 0 else None + pipe = self._redis.pipeline() + pipe.set(self._PAGE_PREFIX + key, page.to_json(), ex=ttl) + for tag in page.tags: + pipe.sadd(self._TAG_PREFIX + tag, key) + await pipe.execute() + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + await self._redis.delete(self._PAGE_PREFIX + key) + + async def acquire_lock(self, key: str, ttl: float) -> bool: + """Acquire the single-flight lock via ``SET NX EX``. + + Args: + key: The cache key. + ttl: Lock expiry in seconds (guards against a crashed holder). + + Returns: + True if the lock was acquired. + """ + acquired = await self._redis.set( + self._LOCK_PREFIX + key, b"1", nx=True, ex=max(1, int(ttl)) + ) + return bool(acquired) + + async def release_lock(self, key: str) -> None: + """Release the single-flight lock. + + Args: + key: The cache key. + """ + await self._redis.delete(self._LOCK_PREFIX + key) + + async def invalidate_tag(self, tag: str) -> int: + """Delete all pages carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + tag_key = self._TAG_PREFIX + tag + # redis.asyncio stubs type set-commands with their sync (non-awaitable) + # return type; smembers is awaitable at runtime. + keys = await self._redis.smembers(tag_key) # pyright: ignore[reportGeneralTypeIssues] + if not keys: + return 0 + page_keys = [ + self._PAGE_PREFIX + (k.decode() if isinstance(k, bytes) else k) + for k in keys + ] + await self._redis.delete(*page_keys, tag_key) + return len(page_keys) + + +def cache_key(build_id: str, path: str) -> str: + """Build the cache key for a page. + + Args: + build_id: The current build identifier (rotates the cache per deploy). + path: The request path. + + Returns: + The cache key. + """ + return f"{build_id}:{path}" + + +class ISRManager: + """Coordinates ISR serving: cache lookup, SWR, and single-flight rendering. + + Args: + cache: The shared cache/coordination backend. + renderer: Produces HTML for a path (e.g. calls the Node render tier). + build_id: Identifier that rotates the cache on each deploy. + default_revalidate: Default seconds-until-stale when a render does not + specify its own. + lock_ttl: Max seconds a single-flight render may hold the lock. + wait_timeout: Max seconds a request waits for another worker's in-flight + render on a cache miss before falling back. + """ + + def __init__( + self, + cache: ISRCache, + renderer: Renderer, + *, + build_id: str = "dev", + default_revalidate: float = 60.0, + lock_ttl: float = 30.0, + wait_timeout: float = 5.0, + ) -> None: + """Store configuration.""" + self.cache = cache + self.renderer = renderer + self.build_id = build_id + self.default_revalidate = default_revalidate + self.lock_ttl = lock_ttl + self.wait_timeout = wait_timeout + self._background: set[asyncio.Task] = set() + + async def get_html(self, path: str) -> str | None: + """Return HTML for ``path`` using ISR semantics. + + * Fresh cache hit -> serve immediately. + * Stale hit -> serve stale immediately, revalidate in the background + (single-flight). + * Miss -> single-flight render; losing waiters briefly poll for the + winner's result, then fall back to ``None`` (caller serves the shell). + + Args: + path: The concrete request path. + + Returns: + The HTML string, or None if it could not be produced. + """ + key = cache_key(self.build_id, path) + page = await self.cache.get(key) + + if page is not None and not page.is_stale(): + return page.html + + if page is not None: + # Stale-while-revalidate: serve stale now, refresh in the background. + self._spawn_revalidate(key, path) + return page.html + + # Cache miss: block on a single-flight render. + return await self._render_single_flight(key, path) + + async def _render_single_flight(self, key: str, path: str) -> str | None: + """Render ``path`` once across workers, or wait for the winner. + + Args: + key: The cache key. + path: The request path. + + Returns: + The rendered HTML, or None on failure/timeout. + """ + if await self.cache.acquire_lock(key, self.lock_ttl): + try: + return await self._render_and_store(key, path) + finally: + await self.cache.release_lock(key) + + # Another worker is rendering; poll briefly for its result. + deadline = time.monotonic() + self.wait_timeout + while time.monotonic() < deadline: + await asyncio.sleep(0.05) + page = await self.cache.get(key) + if page is not None: + return page.html + return None + + async def _render_and_store(self, key: str, path: str) -> str | None: + """Render ``path`` and cache the result. + + Args: + key: The cache key. + path: The request path. + + Returns: + The rendered HTML, or None on failure. + """ + try: + result = await self.renderer(path) + except Exception: + import traceback + + console.warn(f"ISR render failed for {path}: {traceback.format_exc()}") + return None + if result is None: + return None + revalidate = ( + result.revalidate + if result.revalidate is not None + else self.default_revalidate + ) + await self.cache.set( + key, + CachedPage( + html=result.html, + generated_at=time.time(), + revalidate=revalidate, + tags=result.tags, + ), + ) + return result.html + + def _spawn_revalidate(self, key: str, path: str) -> None: + """Schedule a background, single-flight revalidation of ``key``. + + Args: + key: The cache key. + path: The request path. + """ + task = asyncio.create_task(self._revalidate(key, path)) + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def _revalidate(self, key: str, path: str) -> None: + """Re-render and refresh a stale entry if no other worker is doing so. + + Args: + key: The cache key. + path: The request path. + """ + if not await self.cache.acquire_lock(key, self.lock_ttl): + return + try: + await self._render_and_store(key, path) + finally: + await self.cache.release_lock(key) + + async def revalidate_path(self, path: str) -> None: + """Invalidate the cached page for a specific path. + + Args: + path: The path to invalidate. + """ + await self.cache.delete(cache_key(self.build_id, path)) + + async def revalidate_tag(self, tag: str) -> int: + """Invalidate every cached page carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages invalidated. + """ + return await self.cache.invalidate_tag(tag) + + +# --------------------------------------------------------------------------- +# HTTP render tier: the concrete Renderer that calls a separate render service. +# --------------------------------------------------------------------------- + + +class HttpRenderer: + """Renderer that delegates to a render-tier HTTP service. + + The service receives ``{"path": path}`` and returns + ``{"html": ..., "revalidate"?: number, "tags"?: string[]}``. Any error or + non-200 response yields ``None`` so ISR serves stale/shell instead of + caching a failure. + + Args: + render_url: The render-tier endpoint URL. + timeout: Per-request timeout in seconds. + """ + + def __init__(self, render_url: str, *, timeout: float = 10.0) -> None: + """Store the render endpoint and timeout.""" + self._url = render_url + self._timeout = timeout + # A single pooled client is created lazily on first use (inside the + # event loop) and reused, so page-server -> render-tier connections stay + # keep-alive instead of a fresh TCP/TLS handshake per render. + self._client: httpx.AsyncClient | None = None + + def _get_client(self) -> httpx.AsyncClient: + """Return the shared client, creating it on first use. + + Returns: + The pooled async HTTP client. + """ + import httpx + + if self._client is None: + self._client = httpx.AsyncClient(timeout=self._timeout) + return self._client + + async def aclose(self) -> None: + """Close the pooled client (call on page-server shutdown).""" + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __call__(self, path: str) -> RenderResult | None: + """Render ``path`` via the render tier. + + Args: + path: The concrete request path. + + Returns: + The render result, or None on failure. + """ + try: + resp = await self._get_client().post(self._url, json={"path": path}) + if resp.status_code != 200: + console.warn(f"ISR render tier returned {resp.status_code} for {path}") + return None + data = resp.json() + except Exception as exc: + console.warn(f"ISR render tier request failed for {path}: {exc}") + return None + + html = data.get("html") + if not html: + return None + return RenderResult( + html=html, + revalidate=data.get("revalidate"), + tags=tuple(data.get("tags", ())), + ) + + +# --------------------------------------------------------------------------- +# Factories + the dedicated ISR page-server (sits behind nginx proxy_cache). +# --------------------------------------------------------------------------- + + +def is_enabled(config: Config | None = None) -> bool: + """Whether ISR is configured (a render tier URL is set). + + Args: + config: The config to read (defaults to the global config). + + Returns: + True when ``config.isr_render_url`` is set. + """ + from reflex.config import get_config + + config = config if config is not None else get_config() + return config.isr_render_url is not None + + +def get_build_id(config: Config | None = None) -> str: + """Resolve the cache-rotating build id. + + Precedence: ``config.isr_build_id`` -> ``REFLEX_BUILD_ID`` env -> ``"dev"``. + + Args: + config: The config to read (defaults to the global config). + + Returns: + The build id. + """ + from reflex.config import get_config + + config = config if config is not None else get_config() + return config.isr_build_id or os.environ.get("REFLEX_BUILD_ID") or "dev" + + +def get_cache() -> ISRCache: + """Build the ISR cache backend: Redis when available, else in-memory. + + Returns: + A shared Redis-backed cache, or an in-process fallback. + """ + from reflex.utils import prerequisites + + redis = prerequisites.get_redis() + if redis is not None: + return RedisISRCache(redis) + console.warn( + "ISR: no redis_url configured; using an in-memory cache that is NOT " + "shared across workers. Set redis_url for multi-worker deployments." + ) + return MemoryISRCache() + + +async def revalidate_path(path: str, *, config: Config | None = None) -> None: + """Invalidate the ISR cache entry for ``path`` from application code. + + Call this from an event handler after content changes so the next request + re-renders the page. It operates on the shared Redis cache, so a single + call propagates to every page-server worker. No-op when no ``redis_url`` + is configured (nothing is shared to invalidate). + + Args: + path: The path to invalidate (e.g. ``/blog/hello-world``). + config: The config to read the build id from (defaults to global). + """ + from reflex.utils import prerequisites + + redis = prerequisites.get_redis() + if redis is not None: + await RedisISRCache(redis).delete(cache_key(get_build_id(config), path)) + + +async def revalidate_tag(tag: str) -> int: + """Invalidate every ISR page carrying ``tag`` from application code. + + Propagates to all page-server workers via the shared Redis cache. + + Args: + tag: The revalidation tag (e.g. ``post-123``). + + Returns: + The number of pages invalidated (0 when no ``redis_url`` is configured). + """ + from reflex.utils import prerequisites + + redis = prerequisites.get_redis() + if redis is None: + return 0 + return await RedisISRCache(redis).invalidate_tag(tag) + + +def create_manager( + config: Config | None = None, + *, + cache: ISRCache | None = None, + renderer: Renderer | None = None, +) -> ISRManager: + """Assemble an :class:`ISRManager` from config (and optional overrides). + + Args: + config: The config to read (defaults to the global config). + cache: Cache backend override (defaults to :func:`get_cache`). + renderer: Renderer override (defaults to an :class:`HttpRenderer`). + + Returns: + A configured ISR manager. + + Raises: + ValueError: If no renderer is given and ``isr_render_url`` is unset. + """ + from reflex.config import get_config + + config = config if config is not None else get_config() + if renderer is None: + if not config.isr_render_url: + msg = "ISR requires config.isr_render_url (or an explicit renderer)." + raise ValueError(msg) + renderer = HttpRenderer(config.isr_render_url) + return ISRManager( + cache if cache is not None else get_cache(), + renderer, + build_id=get_build_id(config), + default_revalidate=float(config.isr_revalidate), + ) + + +def _load_shell() -> str | None: + """Read the SPA shell HTML from the compiled static build, if present. + + Returns: + The shell HTML, or None if it cannot be found. + """ + from reflex import constants + from reflex.utils import prerequisites + + static_dir = prerequisites.get_web_dir() / constants.Dirs.STATIC + for name in (constants.ReactRouter.SPA_FALLBACK, "index.html"): + candidate = static_dir / name + if candidate.exists(): + return candidate.read_text() + return None + + +def create_isr_app( + manager: ISRManager | None = None, + *, + shell: str | None = None, +) -> Starlette: + """Create the dedicated ISR page-server ASGI app. + + Serves documents via ISR (nginx ``proxy_cache`` is expected in front for + edge caching), falling back to the SPA shell on a miss/render failure, and + exposes ``POST /_isr/revalidate`` for on-demand invalidation. The + revalidation endpoint requires a matching ``X-Reflex-ISR-Token`` header when + the ``REFLEX_ISR_REVALIDATE_TOKEN`` env var is set. + + Run in production as an app factory, e.g. + ``granian --factory reflex.isr:create_isr_app``. + + Args: + manager: The ISR manager (defaults to one built from config). + shell: The SPA shell HTML (defaults to reading the compiled build). + + Returns: + A Starlette app. + """ + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import HTMLResponse, JSONResponse, Response + from starlette.routing import Route + + mgr = manager if manager is not None else create_manager() + shell_html = shell if shell is not None else _load_shell() + revalidate_token = os.environ.get("REFLEX_ISR_REVALIDATE_TOKEN") + + async def serve(request: Request) -> Response: + html = await mgr.get_html(request.url.path) + if html is not None: + return HTMLResponse(html) + if shell_html is not None: + # Miss/failure: serve the SPA shell so the client hydrates. + return HTMLResponse(shell_html) + return Response("Not found", status_code=404) + + async def revalidate(request: Request) -> Response: + if revalidate_token and ( + request.headers.get("x-reflex-isr-token") != revalidate_token + ): + return JSONResponse({"error": "unauthorized"}, status_code=401) + body = await request.json() + if path := body.get("path"): + await mgr.revalidate_path(path) + count = 0 + if tag := body.get("tag"): + count = await mgr.revalidate_tag(tag) + return JSONResponse({"revalidated": True, "tag_pages": count}) + + @contextlib.asynccontextmanager + async def _lifespan(_app: Starlette): + """Close the renderer's pooled HTTP client on shutdown, if any.""" + try: + yield + finally: + aclose = getattr(mgr.renderer, "aclose", None) + if aclose is not None: + await aclose() + + return Starlette( + routes=[ + Route("/_isr/revalidate", revalidate, methods=["POST"]), + Route("/{path:path}", serve, methods=["GET"]), + ], + lifespan=_lifespan, + ) diff --git a/reflex/ssr.py b/reflex/ssr.py index 10dad987dd8..149e961352b 100644 --- a/reflex/ssr.py +++ b/reflex/ssr.py @@ -29,7 +29,7 @@ from starlette.responses import Response from reflex.app import App - from reflex.event import IndividualEventType + from reflex.event import Event from reflex.state import BaseState # Sentinel token/session id used for the stateless SSR render. @@ -68,26 +68,25 @@ def _build_router_data( } -async def _run_on_load_event( - state: BaseState, load_event: IndividualEventType, path: str -) -> None: - """Run a single on_load handler on the ephemeral SSR state. +async def _run_on_load_event(state: BaseState, event: Event, path: str) -> None: + """Run a single resolved on_load event on the ephemeral SSR state. - Resolves the handler's target substate and invokes it directly (rather than - going through the session-managed event queue, which needs a real client - token and state manager). Handlers mutate the state in place; their return - value is consumed but discarded since the whole tree is serialized later. + Resolves the handler's target substate from ``event.name`` and invokes it + directly (rather than going through the session-managed event queue, which + needs a real client token and state manager). Handlers mutate the state in + place; their return value is consumed but discarded since the whole tree is + serialized later. Args: state: The ephemeral root state instance. - load_event: The on_load event handler (EventSpec/EventHandler) to run. + event: The resolved on_load event (``event.name`` is the dotted handler). path: The URL path (for error logging). """ try: - handler = getattr(load_event, "handler", load_event) # e.g. "reflex___state____state.blog_state.on_load" -> substate + method. - full_name = f"{handler.state_full_name}.{handler.fn.__name__}".split(".") - substate = state.get_substate(full_name[:-1]) + *substate_path, method = event.name.split(".") + substate = state.get_substate(substate_path) + handler = substate.event_handlers[method] result = handler.fn(substate) if asyncio.iscoroutine(result): @@ -110,8 +109,13 @@ async def _run_on_load_events(app: App, state: BaseState, path: str) -> None: state: The ephemeral root state instance. path: The URL path (for error logging). """ - for load_event in app.get_load_events(path): - await _run_on_load_event(state, load_event, path) + from reflex.event import Event + + load_events = app.get_load_events(path) + if not load_events: + return + for event in Event.from_event_type(load_events, router_data=state.router_data): + await _run_on_load_event(state, event, path) def ssr_data(app: App): diff --git a/reflex/testing.py b/reflex/testing.py index 5712ad1c6d7..2838c7ef746 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -913,4 +913,3 @@ def stop(self): super().stop() if self.frontend_thread is not None: self.frontend_thread.join() - diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index de9f29e7bbd..8c71d16759f 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -472,12 +472,14 @@ def update_react_router_config(prerender_routes: bool = False): def _update_react_router_config(config: Config, prerender_routes: bool = False): + from reflex_base import ssr + react_router_config = { "basename": config.prepend_frontend_path("/"), "future": { "unstable_optimizeDeps": True, }, - "ssr": False, + "ssr": ssr.ssr_build_enabled(), } if prerender_routes: diff --git a/tests/units/test_isr.py b/tests/units/test_isr.py new file mode 100644 index 00000000000..ab698264ebf --- /dev/null +++ b/tests/units/test_isr.py @@ -0,0 +1,194 @@ +"""Unit tests for the ISR (Incremental Static Regeneration) core.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from reflex.isr import CachedPage, ISRManager, MemoryISRCache, RenderResult, cache_key + + +class FakeRenderer: + """A controllable renderer that counts calls and can block on demand.""" + + def __init__( + self, + html: str = "rendered", + *, + revalidate: float | None = None, + tags: tuple[str, ...] = (), + gate: asyncio.Event | None = None, + fail: bool = False, + ) -> None: + """Configure the fake renderer.""" + self.html = html + self.revalidate = revalidate + self.tags = tags + self.gate = gate + self.fail = fail + self.calls = 0 + + async def __call__(self, path: str) -> RenderResult | None: + """Render the path, optionally blocking on the gate or failing. + + Args: + path: The path being rendered. + + Returns: + A render result, or None when configured to fail. + """ + self.calls += 1 + if self.gate is not None: + await self.gate.wait() + if self.fail: + return None + return RenderResult( + html=f"{self.html}:{path}:{self.calls}", + revalidate=self.revalidate, + tags=self.tags, + ) + + +async def _drain_background(manager: ISRManager) -> None: + """Wait for any background revalidation tasks to finish.""" + if manager._background: + await asyncio.gather(*list(manager._background)) + + +def _manager(renderer, **kwargs) -> ISRManager: + kwargs.setdefault("build_id", "b1") + kwargs.setdefault("default_revalidate", 60.0) + return ISRManager(MemoryISRCache(), renderer, **kwargs) + + +def test_cached_page_staleness(): + """A page is stale once past its revalidate window; 0 means never.""" + now = time.time() + fresh = CachedPage(html="x", generated_at=now, revalidate=60) + assert not fresh.is_stale(now + 30) + assert fresh.is_stale(now + 61) + never = CachedPage(html="x", generated_at=now, revalidate=0) + assert not never.is_stale(now + 10_000) + + +def test_cached_page_roundtrip(): + """CachedPage survives JSON serialization.""" + page = CachedPage(html="

", generated_at=1.0, revalidate=30, tags=("a", "b")) + assert CachedPage.from_json(page.to_json()) == page + + +@pytest.mark.asyncio +async def test_miss_renders_once_and_caches(): + """A cache miss renders and caches; a second request serves from cache.""" + renderer = FakeRenderer() + mgr = _manager(renderer) + + html1 = await mgr.get_html("/page") + html2 = await mgr.get_html("/page") + + assert html1 == html2 + assert renderer.calls == 1 # second request hit the cache + + +@pytest.mark.asyncio +async def test_stale_serves_stale_then_revalidates(): + """A stale entry is served immediately and refreshed in the background.""" + renderer = FakeRenderer() + mgr = _manager(renderer, default_revalidate=0.05) + + first = await mgr.get_html("/p") + assert renderer.calls == 1 + + # Let it go stale. + await asyncio.sleep(0.06) + + stale = await mgr.get_html("/p") + assert stale == first # served the stale copy immediately (no new render yet) + + await _drain_background(mgr) + assert renderer.calls == 2 # background revalidation happened exactly once + + # The refreshed value is now cached. + fresh = await mgr.get_html("/p") + assert fresh != first + assert renderer.calls == 2 + + +@pytest.mark.asyncio +async def test_single_flight_on_concurrent_miss(): + """Concurrent misses for one path trigger exactly one render.""" + gate = asyncio.Event() + renderer = FakeRenderer(gate=gate) + mgr = _manager(renderer, wait_timeout=5.0) + + tasks = [asyncio.create_task(mgr.get_html("/hot")) for _ in range(10)] + await asyncio.sleep(0.1) # let them all reach the lock / poll loop + gate.set() # release the single render + results = await asyncio.gather(*tasks) + + assert renderer.calls == 1 # only one worker rendered + assert len(set(results)) == 1 # everyone got the same HTML + assert results[0] is not None + + +@pytest.mark.asyncio +async def test_revalidate_path_forces_rerender(): + """Invalidating a path drops the cache so the next request re-renders.""" + renderer = FakeRenderer() + mgr = _manager(renderer) + + await mgr.get_html("/p") + assert renderer.calls == 1 + + await mgr.revalidate_path("/p") + await mgr.get_html("/p") + assert renderer.calls == 2 + + +@pytest.mark.asyncio +async def test_revalidate_tag_invalidates_all_matching(): + """Invalidating a tag drops every page carrying it.""" + renderer = FakeRenderer(tags=("blog",)) + mgr = _manager(renderer) + + await mgr.get_html("/blog/a") + await mgr.get_html("/blog/b") + assert renderer.calls == 2 + + count = await mgr.revalidate_tag("blog") + assert count == 2 + + await mgr.get_html("/blog/a") + await mgr.get_html("/blog/b") + assert renderer.calls == 4 # both re-rendered after invalidation + + +@pytest.mark.asyncio +async def test_build_id_scopes_cache(): + """A different build id yields a different key, forcing a re-render.""" + renderer = FakeRenderer() + cache = MemoryISRCache() + + mgr_old = ISRManager(cache, renderer, build_id="old") + mgr_new = ISRManager(cache, renderer, build_id="new") + + await mgr_old.get_html("/p") + await mgr_new.get_html("/p") + + assert renderer.calls == 2 + assert cache_key("old", "/p") != cache_key("new", "/p") + + +@pytest.mark.asyncio +async def test_render_failure_is_not_cached(): + """When the renderer returns None, nothing is cached and None is returned.""" + renderer = FakeRenderer(fail=True) + mgr = _manager(renderer) + + result = await mgr.get_html("/p") + assert result is None + # A subsequent request retries (nothing was cached). + await mgr.get_html("/p") + assert renderer.calls == 2 diff --git a/tests/units/test_isr_app.py b/tests/units/test_isr_app.py new file mode 100644 index 00000000000..fe8dc637af7 --- /dev/null +++ b/tests/units/test_isr_app.py @@ -0,0 +1,138 @@ +"""Tests for the ISR page-server app and factory helpers.""" + +from __future__ import annotations + +import reflex as rx +from reflex.isr import ( + ISRManager, + MemoryISRCache, + RenderResult, + create_isr_app, + get_build_id, + is_enabled, +) + + +class _Renderer: + """Minimal renderer that echoes the path and counts calls.""" + + def __init__(self) -> None: + """Initialize the call counter.""" + self.calls = 0 + + async def __call__(self, path: str) -> RenderResult: + """Render the path. + + Args: + path: The path being rendered. + + Returns: + A render result echoing the path. + """ + self.calls += 1 + return RenderResult(html=f"{path}#{self.calls}", tags=("t",)) + + +def _app(renderer: _Renderer, *, shell: str | None = "shell"): + manager = ISRManager( + MemoryISRCache(), renderer, build_id="b", default_revalidate=60 + ) + return create_isr_app(manager, shell=shell), manager + + +def _client(app): + from starlette.testclient import TestClient + + return TestClient(app) + + +def test_serves_and_caches_rendered_html(): + """The page-server renders once and then serves from cache.""" + renderer = _Renderer() + app, _ = _app(renderer) + client = _client(app) + + r1 = client.get("/blog/hello") + r2 = client.get("/blog/hello") + + assert r1.status_code == 200 + assert "/blog/hello#1" in r1.text + assert r1.text == r2.text + assert renderer.calls == 1 # second request hit the cache + + +def test_falls_back_to_shell_on_render_failure(): + """When the renderer yields no HTML, the SPA shell is served.""" + + class _NullRenderer: + async def __call__(self, path: str) -> None: + return None + + manager = ISRManager(MemoryISRCache(), _NullRenderer(), build_id="b") + app = create_isr_app(manager, shell="SHELL") + client = _client(app) + + r = client.get("/anything") + assert r.status_code == 200 + assert "SHELL" in r.text + + +def test_revalidate_path_endpoint(): + """POST /_isr/revalidate with a path drops that page from cache.""" + renderer = _Renderer() + app, _ = _app(renderer) + client = _client(app) + + client.get("/p") + assert renderer.calls == 1 + + resp = client.post("/_isr/revalidate", json={"path": "/p"}) + assert resp.status_code == 200 + assert resp.json()["revalidated"] is True + + client.get("/p") + assert renderer.calls == 2 # re-rendered after invalidation + + +def test_revalidate_tag_endpoint(): + """POST /_isr/revalidate with a tag invalidates all tagged pages.""" + renderer = _Renderer() + app, _ = _app(renderer) + client = _client(app) + + client.get("/a") + client.get("/b") + assert renderer.calls == 2 + + resp = client.post("/_isr/revalidate", json={"tag": "t"}) + assert resp.json()["tag_pages"] == 2 + + client.get("/a") + client.get("/b") + assert renderer.calls == 4 + + +def test_revalidate_requires_token_when_configured(monkeypatch): + """When REFLEX_ISR_REVALIDATE_TOKEN is set, the endpoint enforces it.""" + monkeypatch.setenv("REFLEX_ISR_REVALIDATE_TOKEN", "secret") + renderer = _Renderer() + app, _ = _app(renderer) + client = _client(app) + + assert client.post("/_isr/revalidate", json={"path": "/p"}).status_code == 401 + ok = client.post( + "/_isr/revalidate", + json={"path": "/p"}, + headers={"X-Reflex-ISR-Token": "secret"}, + ) + assert ok.status_code == 200 + + +def test_config_helpers_reflect_isr_settings(): + """is_enabled / get_build_id read from the config.""" + off = rx.Config(app_name="t") + assert is_enabled(off) is False + + on = rx.Config(app_name="t", isr_render_url="http://render:8000", isr_build_id="v9") + assert is_enabled(on) is True + assert get_build_id(on) == "v9" From 1b735e5e4de357d78637d5f7098d6e5f6f09f83f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 19 Jul 2026 01:45:22 +0200 Subject: [PATCH 14/15] test --- tests/units/test_ssr_data.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py index cda8bdbf739..d7a8a3d506b 100644 --- a/tests/units/test_ssr_data.py +++ b/tests/units/test_ssr_data.py @@ -14,6 +14,39 @@ from reflex.ssr import ssr_data +@pytest.fixture(autouse=True) +def _isolate_ssr_state(clean_registration_context): + """Serialize the /_ssr_data tree against only each test's own states. + + The endpoint calls ``State.dict()``, which walks every registered substate; + with a polluted global registry it would serialize (and crash on) unrelated + states from other modules (e.g. test_app's DynamicState). ``clean_registration_context`` + gives an empty substate registry, but the root ``State``'s per-class dependency + sets are global — reset them to match the clean context (and restore after), + so lookups stay consistent and nothing leaks in or out. + + Args: + clean_registration_context: A fresh, empty registration context. + + Yields: + None. + """ + from reflex.state import State + + names = ("_potentially_dirty_states", "_always_dirty_substates", "_var_dependencies") + original = {name: getattr(State, name) for name in names} + State._potentially_dirty_states = set() + State._always_dirty_substates = set() + State._var_dependencies = {} + State.get_class_substate.cache_clear() + try: + yield + finally: + for name, value in original.items(): + setattr(State, name, value) + State.get_class_substate.cache_clear() + + def _make_request(path: str = "/", headers: dict | None = None) -> Mock: """Create a mock Starlette Request with the given path and headers. From 0f7d74bc22f472fa192f8eb5b4228d137a016f6a Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 19 Jul 2026 01:49:32 +0200 Subject: [PATCH 15/15] ruff --- tests/units/test_ssr_data.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py index d7a8a3d506b..c5d87fd076f 100644 --- a/tests/units/test_ssr_data.py +++ b/tests/units/test_ssr_data.py @@ -33,7 +33,11 @@ def _isolate_ssr_state(clean_registration_context): """ from reflex.state import State - names = ("_potentially_dirty_states", "_always_dirty_substates", "_var_dependencies") + names = ( + "_potentially_dirty_states", + "_always_dirty_substates", + "_var_dependencies", + ) original = {name: getattr(State, name) for name in names} State._potentially_dirty_states = set() State._always_dirty_substates = set()