diff --git a/.github/actions/setup_build_env/action.yml b/.github/actions/setup_build_env/action.yml index 860ee495ed7..773c0f9cb6d 100644 --- a/.github/actions/setup_build_env/action.yml +++ b/.github/actions/setup_build_env/action.yml @@ -34,6 +34,10 @@ runs: prune-cache: false activate-environment: true cache-dependency-glob: "uv.lock" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 - name: Install Dependencies if: inputs.run-uv-sync == 'true' run: uv sync diff --git a/.github/workflows/integration_app_harness.yml b/.github/workflows/integration_app_harness.yml index debacdabf8d..b500e2942e1 100644 --- a/.github/workflows/integration_app_harness.yml +++ b/.github/workflows/integration_app_harness.yml @@ -15,6 +15,7 @@ on: - "**/*.md" env: APP_HARNESS_HEADLESS: 1 + PYTHONUNBUFFERED: 1 permissions: contents: read diff --git a/pyi_hashes.json b/pyi_hashes.json index d180f4deb60..a72a8dbc85f 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,23 +1,23 @@ { - "reflex/__init__.pyi": "3afc0049639ae1663f916dd91e653dfe", - "reflex/components/__init__.pyi": "f3a4ccd8979222bdad1b8649f6734a6d", - "reflex/components/base/__init__.pyi": "e9aaf47be1e1977eacee97b880c8f7de", + "reflex/__init__.pyi": "0ca4974ab0fc44525e061ad39dd338fa", + "reflex/components/__init__.pyi": "f424d22ce0caa9375180195c273eb59c", + "reflex/components/base/__init__.pyi": "74d7764e03837231ea40a06dff08f44a", "reflex/components/base/app_wrap.pyi": "5c2daf49c552c40d1247d998e200d01c", "reflex/components/base/body.pyi": "b1df93ecdea60baf1495ef52aa742f31", - "reflex/components/base/document.pyi": "018f0207324a9cd4bac6d209fe3ae324", + "reflex/components/base/document.pyi": "f6e2dc26993b5481008da554356c6009", "reflex/components/base/error_boundary.pyi": "42779d76ea6bc2847a33533cdac23f21", "reflex/components/base/fragment.pyi": "b77ee8f55c73f45abf515ddbeb29c594", - "reflex/components/base/head.pyi": "31debbe85c37c40d8166504de121f905", "reflex/components/base/link.pyi": "1cdc04cf0fbb2e2ed73feeb67aa55729", "reflex/components/base/meta.pyi": "cdfb479b6140d5768285dfa7713774da", - "reflex/components/base/script.pyi": "29fcfe9d4655bdc020362eb780d0fa46", + "reflex/components/base/script.pyi": "eff80f98522bb4629bac5b55546bbeb2", "reflex/components/base/strict_mode.pyi": "1d05089b3f201d3fcccde3a62a854d34", - "reflex/components/core/__init__.pyi": "44bcee7bc4e27e2f4f4707b843acf291", + "reflex/components/core/__init__.pyi": "d99fbfd4207d8a3f7013221f428e0ed8", "reflex/components/core/auto_scroll.pyi": "fd6e78c419cd4b5ce77512f95603f9c9", "reflex/components/core/banner.pyi": "75f64f49f7f1157f01f1e003737d2eda", - "reflex/components/core/client_side_routing.pyi": "c961a842715a99468110784224140c2c", + "reflex/components/core/client_side_routing.pyi": "df659032437ba52bbb94303fa54e1a46", "reflex/components/core/clipboard.pyi": "e009ca6d0be9127d8acafbe959334ada", "reflex/components/core/debounce.pyi": "72852ec7c60742122689b3c90740d276", + "reflex/components/core/helmet.pyi": "499a351957d7ef03c6b29bbbff699dce", "reflex/components/core/html.pyi": "41c22b41433ec50ba9e07de813b764e6", "reflex/components/core/sticky.pyi": "66c4ce29e7f9419bbb2101278973c0e0", "reflex/components/core/upload.pyi": "90039579d573a7aa887d40889ced6154", @@ -38,13 +38,10 @@ "reflex/components/el/elements/sectioning.pyi": "029b957c3da82d747a297811e9d71a43", "reflex/components/el/elements/tables.pyi": "60ce25fd601003dfadd78d3081b86795", "reflex/components/el/elements/typography.pyi": "3c4d82d4c61c84af85023d180631b081", - "reflex/components/gridjs/datatable.pyi": "963021af448b5f22960d521107194a87", + "reflex/components/gridjs/datatable.pyi": "1b8232fd3e839c6ae772db3d211cd632", "reflex/components/lucide/icon.pyi": "b544769b37f1155ce97a170a21d1bcad", "reflex/components/markdown/markdown.pyi": "3c818ed8f3a99edcba66ff00ec313f13", "reflex/components/moment/moment.pyi": "77e40c9afd511fb046134e2581d8a3bd", - "reflex/components/next/base.pyi": "d445d407bf74ef571712632f1fc24c31", - "reflex/components/next/image.pyi": "bca53a266ce8159cf0e8ff30c65cf913", - "reflex/components/next/link.pyi": "dfb53816a637fb81955b529e7824be47", "reflex/components/plotly/plotly.pyi": "8362d4dd3d5c57af79a8bc8d2dbe82c9", "reflex/components/radix/__init__.pyi": "8d586cbff1d7130d09476ac72ee73400", "reflex/components/radix/primitives/__init__.pyi": "fe8715decf3e9ae471b56bba14e42cb3", @@ -108,7 +105,7 @@ "reflex/components/radix/themes/typography/blockquote.pyi": "5ac7c554663caed708e1a41a183a9bcb", "reflex/components/radix/themes/typography/code.pyi": "5cbff824db591c92bc207066f2c0004e", "reflex/components/radix/themes/typography/heading.pyi": "bb34ff7af2491bd7c424d378940a58c5", - "reflex/components/radix/themes/typography/link.pyi": "330158527b84c9e72ee7abf95d1217d3", + "reflex/components/radix/themes/typography/link.pyi": "97073a20f49b809a2713f0496fd9225c", "reflex/components/radix/themes/typography/text.pyi": "cf2896cf1503d8196c5ca5b06914245f", "reflex/components/react_player/audio.pyi": "c6cadf0f3db3892a7cda64e5672f4465", "reflex/components/react_player/react_player.pyi": "4f1fbff94bbe714e56697a1d0b462bef", diff --git a/reflex/.templates/jinja/app/rxconfig.py.jinja2 b/reflex/.templates/jinja/app/rxconfig.py.jinja2 index 0aed32286f8..242f9fa831d 100644 --- a/reflex/.templates/jinja/app/rxconfig.py.jinja2 +++ b/reflex/.templates/jinja/app/rxconfig.py.jinja2 @@ -3,6 +3,7 @@ import reflex as rx config = rx.Config( app_name="{{ app_name }}", plugins=[ + rx.plugins.SitemapPlugin(), rx.plugins.TailwindV4Plugin(), ], ) diff --git a/reflex/.templates/jinja/web/package.json.jinja2 b/reflex/.templates/jinja/web/package.json.jinja2 index 526f4912afe..77a0b27aa06 100644 --- a/reflex/.templates/jinja/web/package.json.jinja2 +++ b/reflex/.templates/jinja/web/package.json.jinja2 @@ -1,9 +1,9 @@ { "name": "reflex", + "type": "module", "scripts": { "dev": "{{ scripts.dev }}", "export": "{{ scripts.export }}", - "export-sitemap": "{{ scripts.export_sitemap }}", "prod": "{{ scripts.prod }}" }, "dependencies": { diff --git a/reflex/.templates/jinja/web/pages/_app.js.jinja2 b/reflex/.templates/jinja/web/pages/_app.js.jinja2 index 1b299d395d7..51e34370156 100644 --- a/reflex/.templates/jinja/web/pages/_app.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/_app.js.jinja2 @@ -6,8 +6,10 @@ import '$/styles/__reflex_global_styles.css' {% endblock %} {% block declaration %} -import { EventLoopProvider, StateProvider, defaultColorMode } from "$/utils/context.js"; -import { ThemeProvider } from 'next-themes' +import { EventLoopProvider, StateProvider, defaultColorMode } from "$/utils/context"; +import { ThemeProvider } from '$/utils/react-theme'; +import { Layout as AppLayout } from './_document'; +import { Outlet } from 'react-router'; {% for library_alias, library_path in window_libraries %} import * as {{library_alias}} from "{{library_path}}"; {% endfor %} @@ -26,8 +28,9 @@ function AppWrap({children}) { ) } -export default function MyApp({ Component, pageProps }) { - React.useEffect(() => { + +export function Layout({children}) { + React.useEffect(() => { // Make contexts and state objects available globally for dynamic eval'd components let windowImports = { {% for library_alias, library_path in window_libraries %} @@ -36,17 +39,20 @@ export default function MyApp({ Component, pageProps }) { }; window["__reflex"] = windowImports; }, []); - return ( - jsx(ThemeProvider, {defaultTheme:defaultColorMode,attribute:"class"}, + + return jsx(AppLayout, {}, + jsx(ThemeProvider, {defaultTheme: defaultColorMode, attribute: "class"}, jsx(StateProvider, {}, - jsx(EventLoopProvider, {}, - jsx(AppWrap, {}, - jsx(Component, pageProps) - ) + jsx(EventLoopProvider, {}, + jsx(AppWrap, {}, children) ) ) ) ); } +export default function App() { + return jsx(Outlet, {}); +} + {% endblock %} diff --git a/reflex/.templates/jinja/web/pages/_document.js.jinja2 b/reflex/.templates/jinja/web/pages/_document.js.jinja2 index b17731ee0fe..3306f035f7f 100644 --- a/reflex/.templates/jinja/web/pages/_document.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/_document.js.jinja2 @@ -1,7 +1,7 @@ {% extends "web/pages/base_page.js.jinja2" %} {% block export %} -export default function Document() { +export function Layout({children}) { return ( {{utils.render(document)}} ) diff --git a/reflex/.templates/jinja/web/pages/base_page.js.jinja2 b/reflex/.templates/jinja/web/pages/base_page.js.jinja2 index 59248a4ac6a..ff430845550 100644 --- a/reflex/.templates/jinja/web/pages/base_page.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/base_page.js.jinja2 @@ -1,5 +1,4 @@ {% import 'web/pages/utils.js.jinja2' as utils %} -/** @jsxImportSource @emotion/react */ {% block early_imports %} {% endblock %} diff --git a/reflex/.templates/jinja/web/utils/context.js.jinja2 b/reflex/.templates/jinja/web/utils/context.js.jinja2 index 08d3e7f16d6..eb4154172ae 100644 --- a/reflex/.templates/jinja/web/utils/context.js.jinja2 +++ b/reflex/.templates/jinja/web/utils/context.js.jinja2 @@ -1,5 +1,6 @@ -import { createContext, useContext, useMemo, useReducer, useState, createElement } from "react" -import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "$/utils/state.js" +import { createContext, useContext, useMemo, useReducer, useState, createElement, useEffect } from "react" +import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "$/utils/state" +import { jsx } from "@emotion/react"; {% if initial_state %} export const initialState = {{ initial_state|json_dumps }} @@ -77,7 +78,21 @@ export function UploadFilesProvider({ children }) { delete newFilesById[id] return newFilesById }) - return createElement(UploadFilesContext, {value:[filesById, setFilesById]}, children); + return createElement( + UploadFilesContext.Provider, + { value: [filesById, setFilesById] }, + children + ); +} + +export function ClientSide(component) { + return ({ children, ...props }) => { + const [Component, setComponent] = useState(null); + useEffect(() => { + setComponent(component); + }, []); + return Component ? jsx(Component, props, children) : null; + }; } export function EventLoopProvider({ children }) { @@ -87,7 +102,11 @@ export function EventLoopProvider({ children }) { initialEvents, clientStorage, ) - return createElement(EventLoopContext, {value:[addEvents, connectErrors]}, children); + return createElement( + EventLoopContext.Provider, + { value: [addEvents, connectErrors] }, + children + ); } export function StateProvider({ children }) { @@ -106,7 +125,7 @@ export function StateProvider({ children }) { {% for state_name in initial_state %} createElement(StateContexts.{{state_name|var_name}},{value: {{state_name|var_name}}}, {% endfor %} - createElement(DispatchContext.Provider, {value: dispatchers}, children), - {% for state_name in initial_state|reverse %}){% endfor %} + createElement(DispatchContext, {value: dispatchers}, children) + {% for state_name in initial_state %}){% endfor %} ) } diff --git a/reflex/.templates/web/app/entry.client.js b/reflex/.templates/web/app/entry.client.js new file mode 100644 index 00000000000..9545bc28309 --- /dev/null +++ b/reflex/.templates/web/app/entry.client.js @@ -0,0 +1,8 @@ +import { startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; +import { createElement } from "react"; + +startTransition(() => { + hydrateRoot(document, createElement(HydratedRouter)); +}); diff --git a/reflex/.templates/web/app/routes.js b/reflex/.templates/web/app/routes.js new file mode 100644 index 00000000000..59508ff191f --- /dev/null +++ b/reflex/.templates/web/app/routes.js @@ -0,0 +1,10 @@ +import { route } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default [ + route("*", "routes/[404]_._index.js"), + route("404", "routes/[404]_._index.js", { id: "404" }), + ...(await flatRoutes({ + ignoredRouteFiles: ["routes/\\[404\\]_._index.js"], + })), +]; diff --git a/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js b/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js index 25a5a5b68b0..361cd35cf60 100644 --- a/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js +++ b/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js @@ -1,45 +1,19 @@ -import { useTheme } from "next-themes"; -import { useRef, useEffect, useState, createElement } from "react"; +import { useTheme } from "$/utils/react-theme"; +import { createElement } from "react"; import { ColorModeContext, defaultColorMode, isDevMode, lastCompiledTimeStamp, -} from "$/utils/context.js"; +} from "$/utils/context"; export default function RadixThemesColorModeProvider({ children }) { const { theme, resolvedTheme, setTheme } = useTheme(); - const [rawColorMode, setRawColorMode] = useState(defaultColorMode); - const [resolvedColorMode, setResolvedColorMode] = useState( - defaultColorMode === "dark" ? "dark" : "light", - ); - const firstUpdate = useRef(true); - useEffect(() => { - if (firstUpdate.current) { - firstUpdate.current = false; - setRawColorMode(theme); - setResolvedColorMode(resolvedTheme); - } - }); - - useEffect(() => { - if (isDevMode) { - const lastCompiledTimeInLocalStorage = - localStorage.getItem("last_compiled_time"); - if (lastCompiledTimeInLocalStorage !== lastCompiledTimeStamp) { - // on app startup, make sure the application color mode is persisted correctly. - setTheme(defaultColorMode); - localStorage.setItem("last_compiled_time", lastCompiledTimeStamp); - return; - } - } - setRawColorMode(theme); - setResolvedColorMode(resolvedTheme); - }, [theme, resolvedTheme]); const toggleColorMode = () => { setTheme(resolvedTheme === "light" ? "dark" : "light"); }; + const setColorMode = (mode) => { const allowedModes = ["light", "dark", "system"]; if (!allowedModes.includes(mode)) { @@ -50,10 +24,16 @@ export default function RadixThemesColorModeProvider({ children }) { } setTheme(mode); }; + return createElement( - ColorModeContext, + ColorModeContext.Provider, { - value: { rawColorMode, resolvedColorMode, toggleColorMode, setColorMode }, + value: { + rawColorMode: theme, + resolvedColorMode: resolvedTheme, + toggleColorMode, + setColorMode, + }, }, children, ); diff --git a/reflex/.templates/web/next.config.js b/reflex/.templates/web/next.config.js deleted file mode 100644 index fed02db3e3e..00000000000 --- a/reflex/.templates/web/next.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - basePath: "", - compress: true, - reactStrictMode: true, - trailingSlash: true, - output: "", -}; diff --git a/reflex/.templates/web/postcss.config.js b/reflex/.templates/web/postcss.config.js index 616a3624843..3fe1f0379d3 100644 --- a/reflex/.templates/web/postcss.config.js +++ b/reflex/.templates/web/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { "postcss-import": {}, autoprefixer: {}, diff --git a/reflex/.templates/web/react-router.config.js b/reflex/.templates/web/react-router.config.js new file mode 100644 index 00000000000..4758cb88db5 --- /dev/null +++ b/reflex/.templates/web/react-router.config.js @@ -0,0 +1,6 @@ +export default { + future: { + unstable_optimizeDeps: true, + }, + ssr: false, +}; diff --git a/reflex/.templates/web/utils/client_side_routing.js b/reflex/.templates/web/utils/client_side_routing.js index 3589b75ce08..2140c6a229e 100644 --- a/reflex/.templates/web/utils/client_side_routing.js +++ b/reflex/.templates/web/utils/client_side_routing.js @@ -1,41 +1,43 @@ import { useEffect, useRef, useState } from "react"; -import { useRouter } from "next/router"; +import { useLocation, useNavigate } from "react-router-dom"; /** - * React hook for use in /404 page to enable client-side routing. + * React hook for use in NotFound page to enable client-side routing. * - * Uses the next/router to redirect to the provided URL when loading - * the 404 page (for example as a fallback in static hosting situations). + * Uses React Router to redirect to the provided URL when loading + * the NotFound page (for example as a fallback in static hosting situations). * * @returns {boolean} routeNotFound - true if the current route is an actual 404 */ export const useClientSideRouting = () => { const [routeNotFound, setRouteNotFound] = useState(false); const didRedirect = useRef(false); - const router = useRouter(); + const location = useLocation(); + const navigate = useNavigate(); + useEffect(() => { - if ( - router.isReady && - !didRedirect.current // have not tried redirecting yet - ) { - didRedirect.current = true; // never redirect twice to avoid "Hard Navigate" error + if (!didRedirect.current) { + // have not tried redirecting yet + didRedirect.current = true; // never redirect twice to avoid navigation loops + // attempt to redirect to the route in the browser address bar once - router - .replace({ - pathname: window.location.pathname, - query: window.location.search.slice(1), - }) + const path = window.location.pathname; + const search = window.location.search; + + // Use navigate instead of replace + navigate(path + search, { replace: true }) .then(() => { - // Check if the current route is /404 - if (router.pathname === "/404") { + // Check if we're still on a NotFound route + // Note: This depends on how your routes are set up + if (location.pathname === path) { setRouteNotFound(true); // Mark as an actual 404 } }) - .catch((e) => { + .catch(() => { setRouteNotFound(true); // navigation failed, so this is a real 404 }); } - }, [router.isReady]); + }, [location, navigate]); // Return the reactive bool, to avoid flashing 404 page until we know for sure // the route is not found. diff --git a/reflex/.templates/web/utils/react-theme.js b/reflex/.templates/web/utils/react-theme.js new file mode 100644 index 00000000000..9770730a2d6 --- /dev/null +++ b/reflex/.templates/web/utils/react-theme.js @@ -0,0 +1,89 @@ +import { + createContext, + useContext, + useState, + useEffect, + createElement, + useRef, + useMemo, +} from "react"; + +import { isDevMode, lastCompiledTimeStamp } from "$/utils/context"; + +const ThemeContext = createContext(); + +export function ThemeProvider({ children, defaultTheme = "system" }) { + const [theme, setTheme] = useState(defaultTheme); + const [systemTheme, setSystemTheme] = useState( + defaultTheme !== "system" ? defaultTheme : "light", + ); + + const firstRender = useRef(true); + + useEffect(() => { + if (!firstRender.current) { + return; + } + + firstRender.current = false; + + if (isDevMode) { + const lastCompiledTimeInLocalStorage = + localStorage.getItem("last_compiled_time"); + if (lastCompiledTimeInLocalStorage !== lastCompiledTimeStamp) { + // on app startup, make sure the application color mode is persisted correctly. + localStorage.setItem("last_compiled_time", lastCompiledTimeStamp); + return; + } + } + + // Load saved theme from localStorage + const savedTheme = localStorage.getItem("theme") || defaultTheme; + setTheme(savedTheme); + }); + + const resolvedTheme = useMemo( + () => (theme === "system" ? systemTheme : theme), + [theme, systemTheme], + ); + + useEffect(() => { + // Set up media query for system preference detection + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + // Listen for system preference changes + const handleChange = () => { + setSystemTheme(mediaQuery.matches ? "dark" : "light"); + }; + + handleChange(); + + mediaQuery.addEventListener("change", handleChange); + + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; + }); + + // Save theme to localStorage whenever it changes + useEffect(() => { + localStorage.setItem("theme", theme); + }, [theme]); + + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(resolvedTheme); + root.style.colorScheme = resolvedTheme; + }, [resolvedTheme]); + + return createElement( + ThemeContext.Provider, + { value: { theme, resolvedTheme, setTheme } }, + children, + ); +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 58eb3f1771a..08945d52bf9 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -6,14 +6,19 @@ import env from "$/env.json"; import reflexEnvironment from "$/reflex.json"; import Cookies from "universal-cookie"; import { useEffect, useRef, useState } from "react"; -import Router, { useRouter } from "next/router"; +import { + useLocation, + useNavigate, + useSearchParams, + useParams, +} from "react-router"; import { initialEvents, initialState, onLoadInternalEvent, state_name, exception_state_name, -} from "$/utils/context.js"; +} from "$/utils/context"; import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; @@ -158,24 +163,48 @@ export const evalReactComponent = async (component) => { * Only Queue and process events when websocket connection exists. * @param event The event to queue. * @param socket The socket object to send the event on. + * @param navigate The navigate function from React Router + * @param params The params object from React Router * * @returns Adds event to queue and processes it if websocket exits, does nothing otherwise. */ -export const queueEventIfSocketExists = async (events, socket) => { +export const queueEventIfSocketExists = async ( + events, + socket, + navigate, + params, +) => { if (!socket) { return; } - await queueEvents(events, socket); + await queueEvents(events, socket, navigate, params); }; +/** + * Check if a string is a valid HTTP URL. + * @param string The string to check. + * + * @returns The URL object if valid, undefined otherwise. + * */ +function urlFrom(string) { + try { + return new URL(string); + } catch { + return undefined; + } + return undefined; +} + /** * Handle frontend event or send the event to the backend via Websocket. * @param event The event to send. * @param socket The socket object to send the event on. + * @param navigate The navigate function from useNavigate + * @param params The params object from useParams * * @returns True if the event was sent, false if it was handled locally. */ -export const applyEvent = async (event, socket) => { +export const applyEvent = async (event, socket, navigate, params) => { // Handle special events if (event.name == "_redirect") { if ((event.payload.path ?? undefined) === undefined) { @@ -183,41 +212,54 @@ export const applyEvent = async (event, socket) => { } if (event.payload.external) { window.open(event.payload.path, "_blank", "noopener"); - } else if (event.payload.replace) { - Router.replace(event.payload.path); + return false; + } + const url = urlFrom(event.payload.path); + let pathname = event.payload.path; + if (url) { + if (url.host !== window.location.host) { + // External URL + window.location.assign(event.payload.path); + return false; + } else { + pathname = url.pathname + url.search + url.hash; + } + } + if (event.payload.replace) { + navigate(pathname, { replace: true }); } else { - Router.push(event.payload.path); + navigate(pathname); } return false; } if (event.name == "_remove_cookie") { cookies.remove(event.payload.key, { ...event.payload.options }); - queueEventIfSocketExists(initialEvents(), socket); + queueEventIfSocketExists(initialEvents(), socket, navigate, params); return false; } if (event.name == "_clear_local_storage") { localStorage.clear(); - queueEventIfSocketExists(initialEvents(), socket); + queueEventIfSocketExists(initialEvents(), socket, navigate, params); return false; } if (event.name == "_remove_local_storage") { localStorage.removeItem(event.payload.key); - queueEventIfSocketExists(initialEvents(), socket); + queueEventIfSocketExists(initialEvents(), socket, navigate, params); return false; } if (event.name == "_clear_session_storage") { sessionStorage.clear(); - queueEvents(initialEvents(), socket); + queueEvents(initialEvents(), socket, navigate, params); return false; } if (event.name == "_remove_session_storage") { sessionStorage.removeItem(event.payload.key); - queueEvents(initialEvents(), socket); + queueEvents(initialEvents(), socket, navigate, params); return false; } @@ -322,11 +364,15 @@ export const applyEvent = async (event, socket) => { event.router_data === undefined || Object.keys(event.router_data).length === 0 ) { - event.router_data = (({ pathname, query, asPath }) => ({ - pathname, - query, - asPath, - }))(Router); + // Since we don't have router directly, we need to get info from our hooks + event.router_data = { + pathname: window.location.pathname, + query: { + ...Object.fromEntries(new URLSearchParams(window.location.search)), + ...params(), + }, + asPath: window.location.pathname + window.location.search, + }; } // Send the event to the server. @@ -342,15 +388,22 @@ export const applyEvent = async (event, socket) => { * Send an event to the server via REST. * @param event The current event. * @param socket The socket object to send the response event(s) on. + * @param navigate The navigate function from React Router + * @param params The params object from React Router * * @returns Whether the event was sent. */ -export const applyRestEvent = async (event, socket) => { +export const applyRestEvent = async (event, socket, navigate, params) => { let eventSent = false; if (event.handler === "uploadFiles") { if (event.payload.files === undefined || event.payload.files.length === 0) { // Submit the event over the websocket to trigger the event handler. - return await applyEvent(Event(event.name, { files: [] }), socket); + return await applyEvent( + Event(event.name, { files: [] }), + socket, + navigate, + params, + ); } // Start upload, but do not wait for it, which would block other events. @@ -371,8 +424,16 @@ export const applyRestEvent = async (event, socket) => { * @param events Array of events to queue. * @param socket The socket object to send the event on. * @param prepend Whether to place the events at the beginning of the queue. + * @param navigate The navigate function from React Router + * @param params The params object from React Router */ -export const queueEvents = async (events, socket, prepend) => { +export const queueEvents = async ( + events, + socket, + prepend, + navigate, + params, +) => { if (prepend) { // Drain the existing queue and place it after the given events. events = [ @@ -383,14 +444,16 @@ export const queueEvents = async (events, socket, prepend) => { ]; } event_queue.push(...events.filter((e) => e !== undefined && e !== null)); - await processEvent(socket.current); + await processEvent(socket.current, navigate, params); }; /** * Process an event off the event queue. * @param socket The socket object to send the event on. + * @param navigate The navigate function from React Router + * @param params The params object from React Router */ -export const processEvent = async (socket) => { +export const processEvent = async (socket, navigate, params) => { // Only proceed if the socket is up and no event in the queue uses state, otherwise we throw the event into the void if (!socket && isStateful()) { return; @@ -410,16 +473,16 @@ export const processEvent = async (socket) => { let eventSent = false; // Process events with handlers via REST and all others via websockets. if (event.handler) { - eventSent = await applyRestEvent(event, socket); + eventSent = await applyRestEvent(event, socket, navigate, params); } else { - eventSent = await applyEvent(event, socket); + eventSent = await applyEvent(event, socket, navigate, params); } // If no event was sent, set processing to false. if (!eventSent) { event_processing = false; // recursively call processEvent to drain the queue, since there is // no state update to trigger the useEffect event loop. - await processEvent(socket); + await processEvent(socket, navigate, params); } }; @@ -430,6 +493,8 @@ export const processEvent = async (socket) => { * @param transports The transports to use. * @param setConnectErrors The function to update connection error value. * @param client_storage The client storage object from context.js + * @param navigate The navigate function from React Router + * @param params The params object from React Router */ export const connect = async ( socket, @@ -437,6 +502,8 @@ export const connect = async ( transports, setConnectErrors, client_storage = {}, + navigate, + params, ) => { // Get backend URL object from the endpoint. const endpoint = getBackendURL(EVENTURL); @@ -511,12 +578,12 @@ export const connect = async ( applyClientStorageDelta(client_storage, update.delta); event_processing = !update.final; if (update.events) { - queueEvents(update.events, socket); + queueEvents(update.events, socket, false, navigate, params); } }); socket.current.on("reload", async (event) => { event_processing = false; - queueEvents([...initialEvents(), event], socket, true); + queueEvents([...initialEvents(), event], socket, true, navigate, params); }); document.addEventListener("visibilitychange", checkVisibility); @@ -748,7 +815,7 @@ const applyClientStorageDelta = (client_storage, delta) => { }; /** - * Establish websocket event loop for a NextJS page. + * Establish websocket event loop for a React Router page. * @param dispatch The reducer dispatch function to update state. * @param initial_events The initial app events. * @param client_storage The client storage object from context.js @@ -763,8 +830,17 @@ export const useEventLoop = ( client_storage = {}, ) => { const socket = useRef(null); - const router = useRouter(); + const location = useLocation(); + const navigate = useNavigate(); + const paramsR = useParams(); + const prevLocationRef = useRef(location); + const [searchParams] = useSearchParams(); const [connectErrors, setConnectErrors] = useState([]); + const params = useRef(paramsR); + + useEffect(() => { + params.current = paramsR; + }, [paramsR]); // Function to add new events to the event queue. const addEvents = (events, args, event_actions) => { @@ -803,32 +879,38 @@ export const useEventLoop = ( // If debounce is used, queue the events after some delay debounce( combined_name, - () => queueEvents(_events, socket), + () => + queueEvents(_events, socket, false, navigate, () => params.current), event_actions.debounce, ); } else { - queueEvents(_events, socket); + queueEvents(_events, socket, false, navigate, () => params.current); } }; const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { - if (router.isReady && !sentHydrate.current) { + if (!sentHydrate.current) { queueEvents( initial_events().map((e) => ({ ...e, - router_data: (({ pathname, query, asPath }) => ({ - pathname, - query, - asPath, - }))(router), + router_data: { + pathname: location.pathname, + query: { + ...Object.fromEntries(searchParams.entries()), + ...params.current, + }, + asPath: location.pathname + location.search, + }, })), socket, true, + navigate, + () => params.current, ); sentHydrate.current = true; } - }, [router.isReady]); + }, []); // Handle frontend errors and send them to the backend via websocket. useEffect(() => { @@ -871,6 +953,8 @@ export const useEventLoop = ( ["websocket"], setConnectErrors, client_storage, + navigate, + () => params.current, ); } } @@ -885,14 +969,14 @@ export const useEventLoop = ( // Main event loop. useEffect(() => { - // Skip if the router is not ready. - if (!router.isReady || isBackendDisabled()) { + // Skip if the backend is disabled + if (isBackendDisabled()) { return; } (async () => { // Process all outstanding events. while (event_queue.length > 0 && !event_processing) { - await processEvent(socket.current); + await processEvent(socket.current, navigate, () => params.current); } })(); }); @@ -928,31 +1012,32 @@ export const useEventLoop = ( return () => window.removeEventListener("storage", handleStorage); }); - // Route after the initial page hydration. + // Route after the initial page hydration useEffect(() => { - const change_start = () => { - const main_state_dispatch = dispatch["reflex___state____state"]; - if (main_state_dispatch !== undefined) { - main_state_dispatch({ is_hydrated: false }); - } - }; - const change_complete = () => addEvents(onLoadInternalEvent()); - const change_error = () => { - // Remove cached error state from router for this page, otherwise the - // page will never send on_load events again. - if (router.components[router.pathname].error) { - delete router.components[router.pathname].error; - } - }; - router.events.on("routeChangeStart", change_start); - router.events.on("routeChangeComplete", change_complete); - router.events.on("routeChangeError", change_error); - return () => { - router.events.off("routeChangeStart", change_start); - router.events.off("routeChangeComplete", change_complete); - router.events.off("routeChangeError", change_error); - }; - }, [router]); + // This will run when the location changes + if ( + location.pathname + location.search + location.hash !== + prevLocationRef.current.pathname + + prevLocationRef.current.search + + prevLocationRef.current.hash + ) { + // Equivalent to routeChangeStart - runs when navigation begins + const change_start = () => { + const main_state_dispatch = dispatch["reflex___state____state"]; + if (main_state_dispatch !== undefined) { + main_state_dispatch({ is_hydrated: false }); + } + }; + change_start(); + + // Equivalent to routeChangeComplete - runs after navigation completes + const change_complete = () => addEvents(onLoadInternalEvent()); + change_complete(); + + // Update the ref + prevLocationRef.current = location; + } + }, [location, dispatch, onLoadInternalEvent, addEvents]); return [addEvents, connectErrors]; }; diff --git a/reflex/.templates/web/vite.config.js b/reflex/.templates/web/vite.config.js new file mode 100644 index 00000000000..20bb8b676e3 --- /dev/null +++ b/reflex/.templates/web/vite.config.js @@ -0,0 +1,32 @@ +import { fileURLToPath, URL } from "url"; +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + +export default defineConfig((config) => ({ + plugins: [reactRouter()], + build: { + rollupOptions: { + jsx: {}, + }, + }, + server: { + port: process.env.PORT, + }, + resolve: { + mainFields: ["browser", "module", "jsnext"], + alias: [ + { + find: "$", + replacement: fileURLToPath(new URL("./", import.meta.url)), + }, + { + find: "@", + replacement: fileURLToPath(new URL("./public", import.meta.url)), + }, + ].concat( + config.command === "build" + ? [{ find: "react-dom/server", replacement: "react-dom/server.node" }] + : [], + ), + }, +})); diff --git a/reflex/__init__.py b/reflex/__init__.py index aab875c7caa..58dabbd02f0 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -279,7 +279,7 @@ "components.el.elements.media": ["image"], "components.lucide": ["icon"], **COMPONENTS_BASE_MAPPING, - "components": ["el", "radix", "lucide", "recharts", "next"], + "components": ["el", "radix", "lucide", "recharts"], "components.markdown": ["markdown"], **RADIX_MAPPING, "components.plotly": ["plotly"], diff --git a/reflex/app.py b/reflex/app.py index 617bf523e2e..610a436efb4 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -13,7 +13,7 @@ import json import sys import traceback -from collections.abc import AsyncIterator, Callable, Coroutine, Sequence +from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence from datetime import datetime from pathlib import Path from timeit import default_timer as timer @@ -60,7 +60,7 @@ ) from reflex.components.core.breakpoints import set_breakpoints from reflex.components.core.client_side_routing import ( - Default404Page, + default_404_page, wait_for_client_redirect, ) from reflex.components.core.sticky import sticky @@ -273,8 +273,8 @@ class UnevaluatedPage: description: Var | str | None image: str on_load: EventType[()] | None - meta: list[dict[str, str]] - context: dict[str, Any] | None + meta: Sequence[Mapping[str, str]] + context: Mapping[str, Any] def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage: """Merge the other page into this one. @@ -580,7 +580,7 @@ def __call__(self) -> ASGIApp: self._apply_decorated_pages() compile_future = concurrent.futures.ThreadPoolExecutor(max_workers=1).submit( - self._compile + self._compile, prerender_routes=is_prod_mode() ) def callback(f: concurrent.futures.Future): @@ -761,7 +761,7 @@ def add_page( if route == constants.Page404.SLUG: if component is None: - component = Default404Page.create() + component = default_404_page component = wait_for_client_redirect(self._generate_component(component)) title = title or constants.Page404.TITLE description = description or constants.Page404.DESCRIPTION @@ -782,7 +782,7 @@ def add_page( image=image, on_load=on_load, meta=meta, - context=context, + context=context or {}, ) if route in self._unevaluated_pages: @@ -853,10 +853,39 @@ def get_load_events(self, route: str) -> list[IndividualEventType[()]]: Returns: The load events for the route. """ - route = route.lstrip("/") + route = route.lstrip("/").rstrip("/") if route == "": - route = constants.PageNames.INDEX_ROUTE - return self._load_events.get(route, []) + return self._load_events.get(constants.PageNames.INDEX_ROUTE, []) + + # Separate the pages by route type. + static_page_paths_to_page_route = {} + dynamic_page_paths_to_page_route = {} + for page_route in list(self._pages) + list(self._unevaluated_pages): + page_path = page_route.lstrip("/").rstrip("/") + if "[" in page_path and "]" in page_path: + dynamic_page_paths_to_page_route[page_path] = page_route + else: + static_page_paths_to_page_route[page_path] = page_route + + # Check for static routes. + if (page_route := static_page_paths_to_page_route.get(route)) is not None: + return self._load_events.get(page_route, []) + + # Check for dynamic routes. + parts = route.split("/") + for page_path, page_route in dynamic_page_paths_to_page_route.items(): + page_parts = page_path.split("/") + if len(page_parts) != len(parts): + continue + if all( + part == page_part + or (page_part.startswith("[") and page_part.endswith("]")) + for part, page_part in zip(parts, page_parts, strict=False) + ): + return self._load_events.get(page_route, []) + + # Default to 404 page load events if no match found. + return self._load_events.get("404", []) def _check_routes_conflict(self, new_route: str): """Verify if there is any conflict between the new route and any existing route. @@ -947,7 +976,7 @@ def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]): for i, tags in imports.items() if i not in dependencies and i not in dev_dependencies - and not any(i.startswith(prefix) for prefix in ["/", "$/", ".", "next/"]) + and not any(i.startswith(prefix) for prefix in ["/", "$/", "."]) and i != "" and any(tag.install for tag in tags) } @@ -1077,11 +1106,11 @@ def _validate_var_dependencies(self, state: type[BaseState] | None = None) -> No for substate in state.class_subclasses: self._validate_var_dependencies(substate) - def _compile(self, export: bool = False, dry_run: bool = False): + def _compile(self, prerender_routes: bool = False, dry_run: bool = False): """Compile the app and output it to the pages folder. Args: - export: Whether to compile the app for export. + prerender_routes: Whether to prerender the routes. dry_run: Whether to compile the app without saving it. Raises: @@ -1353,6 +1382,7 @@ def _submit_work_without_advancing( ) ) ), + unevaluated_pages=list(self._unevaluated_pages.values()), ) # Wait for all compilation tasks to complete. @@ -1396,15 +1426,9 @@ def _submit_work_without_advancing( with console.timing("Install Frontend Packages"): self._get_frontend_packages(all_imports) - # Setup the next.config.js - transpile_packages = [ - package - for package, import_vars in all_imports.items() - if any(import_var.transpile for import_var in import_vars) - ] - prerequisites.update_next_config( - export=export, - transpile_packages=transpile_packages, + # Setup the react-router.config.js + prerequisites.update_react_router_config( + prerender_routes=prerender_routes, ) if is_prod_mode(): @@ -1413,9 +1437,11 @@ def _submit_work_without_advancing( else: # In dev mode, delete removed pages and update existing pages. keep_files = [Path(output_path) for output_path, _ in compile_results] - for p in Path(prerequisites.get_web_dir() / constants.Dirs.PAGES).rglob( - "*" - ): + for p in Path( + prerequisites.get_web_dir() + / constants.Dirs.PAGES + / constants.Dirs.ROUTES + ).rglob("*"): if p.is_file() and p not in keep_files: # Remove pages that are no longer in the app. p.unlink() diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index cebf1069cc2..8ad956fd142 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -469,7 +469,9 @@ def compile_document_root( The path and code of the compiled document root. """ # Get the path for the output file. - output_path = utils.get_page_path(constants.PageNames.DOCUMENT_ROOT) + output_path = str( + get_web_dir() / constants.Dirs.PAGES / constants.PageNames.DOCUMENT_ROOT + ) # Create the document root. document_root = utils.create_document_root( @@ -491,7 +493,9 @@ def compile_app(app_root: Component) -> tuple[str, str]: The path and code of the compiled app wrapper. """ # Get the path for the output file. - output_path = utils.get_page_path(constants.PageNames.APP_ROOT) + output_path = str( + get_web_dir() / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ) # Compile the document root. code = _compile_app(app_root) @@ -606,7 +610,7 @@ def purge_web_pages_dir(): return # Empty out the web pages directory. - utils.empty_dir(get_web_dir() / constants.Dirs.PAGES, keep_files=["_app.js"]) + utils.empty_dir(get_web_dir() / constants.Dirs.PAGES, keep_files=["routes.js"]) if TYPE_CHECKING: diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 8d406d4639a..f908a0794f7 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -12,19 +12,13 @@ from urllib.parse import urlparse from reflex import constants -from reflex.components.base import ( - Body, - Description, - DocumentHead, - Head, - Html, - Image, - Main, - Meta, - NextScript, - Title, -) +from reflex.components.base import Description, Image, Scripts +from reflex.components.base.document import Links, ScrollRestoration +from reflex.components.base.document import Meta as ReactMeta from reflex.components.component import Component, ComponentStyle, CustomComponent +from reflex.components.el.elements.metadata import Head, Meta, Title +from reflex.components.el.elements.other import Html +from reflex.components.el.elements.sectioning import Body from reflex.istate.storage import Cookie, LocalStorage, SessionStorage from reflex.state import BaseState, _resolve_delta from reflex.style import Style @@ -318,6 +312,8 @@ def compile_custom_component( if lib != component.library } + imports.setdefault("@emotion/react", []).append(ImportVar("jsx")) + # Concatenate the props. props = list(component.props) @@ -350,12 +346,27 @@ def create_document_root( Returns: The document root. """ - head_components = head_components or [] + head_components = [ + *( + head_components + or [ + # Default meta tags if user does not provide. + Meta.create(char_set="utf-8"), + Meta.create( + name="viewport", content="width=device-width, initial-scale=1" + ), + ] + ), + # Always include the framework meta and link tags. + ReactMeta.create(), + Links.create(), + ] return Html.create( - DocumentHead.create(*head_components), + Head.create(*head_components), Body.create( - Main.create(), - NextScript.create(), + Var("children"), + ScrollRestoration.create(), + Scripts.create(), ), lang=html_lang or "en", custom_attrs=html_custom_attrs or {}, @@ -389,6 +400,21 @@ def create_theme(style: ComponentStyle) -> dict: return {"styles": {"global": root_style}} +def _format_route_part(part: str) -> str: + if part.startswith("[") and part.endswith("]"): + return f"${part}_" + return "[" + part + "]_" + + +def _path_to_file_stem(path: str) -> str: + if path == "index": + return "_index" + path = path if path != "index" else "/" + return ( + ".".join([_format_route_part(part) for part in path.split("/")]) + "._index" + ).lstrip(".") + + def get_page_path(path: str) -> str: """Get the path of the compiled JS file for the given page. @@ -398,7 +424,12 @@ def get_page_path(path: str) -> str: Returns: The path of the compiled JS file. """ - return str(get_web_dir() / constants.Dirs.PAGES / (path + constants.Ext.JS)) + return str( + get_web_dir() + / constants.Dirs.PAGES + / constants.Dirs.ROUTES + / (_path_to_file_stem(path) + constants.Ext.JS) + ) def get_theme_path() -> str: @@ -490,12 +521,8 @@ def add_meta( children.append(Description.create(content=description)) children.append(Image.create(content=image)) - page.children.append( - Head.create( - *children, - *meta_tags, - ) - ) + page.children.extend(children) + page.children.extend(meta_tags) return page diff --git a/reflex/components/__init__.py b/reflex/components/__init__.py index f8e0eea9a6d..d4a9870a22e 100644 --- a/reflex/components/__init__.py +++ b/reflex/components/__init__.py @@ -25,7 +25,6 @@ "Component", "NoSSRComponent", ], - "next": ["NextLink", "next_link"], } __getattr__, __dir__, __all__ = lazy_loader.attach( __name__, diff --git a/reflex/components/base/__init__.py b/reflex/components/base/__init__.py index 51c369bb9bc..6e4cfd66b3c 100644 --- a/reflex/components/base/__init__.py +++ b/reflex/components/base/__init__.py @@ -8,7 +8,7 @@ _SUBMOD_ATTRS: dict[str, list[str]] = { "body": ["Body"], - "document": ["DocumentHead", "Html", "Main", "NextScript"], + "document": ["Scripts", "Outlet", "ScrollRestoration", "Links", "Meta"], "fragment": [ "Fragment", "fragment", @@ -17,10 +17,6 @@ "ErrorBoundary", "error_boundary", ], - "head": [ - "head", - "Head", - ], "link": ["RawLink", "ScriptTag"], "meta": ["Description", "Image", "Meta", "Title"], "script": ["Script", "script"], diff --git a/reflex/components/base/document.py b/reflex/components/base/document.py index 3f899ec7490..93979c5451b 100644 --- a/reflex/components/base/document.py +++ b/reflex/components/base/document.py @@ -3,33 +3,37 @@ from reflex.components.component import Component -class NextDocumentLib(Component): +class ReactRouterLib(Component): """Root document components.""" - library = "next/document" + library = "react-router" -class Html(NextDocumentLib): - """The document html.""" +class Meta(ReactRouterLib): + """The document meta tags.""" - tag = "Html" + tag = "Meta" - lang: str | None +class Links(ReactRouterLib): + """The document link tags.""" -class DocumentHead(NextDocumentLib): - """The document head.""" + tag = "Links" - tag = "Head" +class ScrollRestoration(ReactRouterLib): + """The document scroll restoration.""" -class Main(NextDocumentLib): - """The document main section.""" + tag = "ScrollRestoration" - tag = "Main" +class Outlet(ReactRouterLib): + """The document outlet.""" -class NextScript(NextDocumentLib): + tag = "Outlet" + + +class Scripts(ReactRouterLib): """The document main scripts.""" - tag = "NextScript" + tag = "Scripts" diff --git a/reflex/components/base/head.py b/reflex/components/base/head.py deleted file mode 100644 index b130ed22f60..00000000000 --- a/reflex/components/base/head.py +++ /dev/null @@ -1,20 +0,0 @@ -"""The head component.""" - -from reflex.components.component import Component, MemoizationLeaf - - -class NextHeadLib(Component): - """Header components.""" - - library = "next/head" - - -class Head(NextHeadLib, MemoizationLeaf): - """Head Component.""" - - tag = "NextHead" - - is_default = True - - -head = Head.create diff --git a/reflex/components/base/script.py b/reflex/components/base/script.py index 9bca2fbb78f..34ec3f08ad8 100644 --- a/reflex/components/base/script.py +++ b/reflex/components/base/script.py @@ -1,76 +1,78 @@ -"""Next.js script wrappers and inline script functionality. - -https://nextjs.org/docs/app/api-reference/components/script -""" +"""Wrapper for the script element. Uses the Helmet component to manage the head.""" from __future__ import annotations -from typing import Literal - -from reflex.components.component import Component -from reflex.event import EventHandler, no_args_event_spec -from reflex.vars.base import LiteralVar, Var - - -class Script(Component): - """Next.js script component. - - Note that this component differs from reflex.components.base.document.NextScript - in that it is intended for use with custom and user-defined scripts. - - It also differs from reflex.components.base.link.ScriptTag, which is the plain - HTML