Client-side Routing (404 redirect) (#1695)
This commit is contained in:
parent
03a92bc60e
commit
38c5503f94
@ -7,12 +7,8 @@ handle @backend_routes {
|
|||||||
reverse_proxy app:8000
|
reverse_proxy app:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
root * /srv
|
||||||
route {
|
route {
|
||||||
try_files {path} {path}.html
|
try_files {path} {path}/ /404.html
|
||||||
file_server {
|
file_server
|
||||||
root /srv
|
|
||||||
pass_thru
|
|
||||||
}
|
|
||||||
# proxy dynamic routes to nextjs server
|
|
||||||
reverse_proxy app:3000
|
|
||||||
}
|
}
|
||||||
|
@ -8,9 +8,6 @@ ARG API_URL
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Reflex will install bun, nvm, and node to `$HOME/.reflex` (/app/.reflex)
|
|
||||||
ENV HOME=/app
|
|
||||||
|
|
||||||
# Create virtualenv which will be copied into final container
|
# Create virtualenv which will be copied into final container
|
||||||
ENV VIRTUAL_ENV=/app/.venv
|
ENV VIRTUAL_ENV=/app/.venv
|
||||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
@ -22,9 +19,13 @@ RUN pip install -r requirements.txt
|
|||||||
# Deploy templates and prepare app
|
# Deploy templates and prepare app
|
||||||
RUN reflex init
|
RUN reflex init
|
||||||
|
|
||||||
# Export static copy of frontend to /app/.web/_static (and pre-install frontend packages)
|
# Export static copy of frontend to /app/.web/_static
|
||||||
RUN reflex export --frontend-only --no-zip
|
RUN reflex export --frontend-only --no-zip
|
||||||
|
|
||||||
|
# Copy static files out of /app to save space in backend image
|
||||||
|
RUN mv .web/_static /tmp/_static
|
||||||
|
RUN rm -rf .web && mkdir .web
|
||||||
|
RUN mv /tmp/_static .web/_static
|
||||||
|
|
||||||
# Stage 2: copy artifacts into slim image
|
# Stage 2: copy artifacts into slim image
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
@ -35,4 +36,4 @@ COPY --chown=reflex --from=init /app /app
|
|||||||
USER reflex
|
USER reflex
|
||||||
ENV PATH="/app/.venv/bin:$PATH" API_URL=$API_URL
|
ENV PATH="/app/.venv/bin:$PATH" API_URL=$API_URL
|
||||||
|
|
||||||
CMD reflex db migrate && reflex run --env prod
|
CMD reflex db migrate && reflex run --env prod --backend-only
|
||||||
|
@ -5,6 +5,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from reflex.testing import AppHarness, AppHarnessProd
|
||||||
|
|
||||||
DISPLAY = None
|
DISPLAY = None
|
||||||
XVFB_DIMENSIONS = (800, 600)
|
XVFB_DIMENSIONS = (800, 600)
|
||||||
|
|
||||||
@ -57,3 +59,18 @@ def pytest_exception_interact(node, call, report):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to take screenshot for {node}: {e}")
|
print(f"Failed to take screenshot for {node}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(
|
||||||
|
scope="session", params=[AppHarness, AppHarnessProd], ids=["dev", "prod"]
|
||||||
|
)
|
||||||
|
def app_harness_env(request):
|
||||||
|
"""Parametrize the AppHarness class to use for the test, either dev or prod.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The pytest fixture request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The AppHarness class to use for the test.
|
||||||
|
"""
|
||||||
|
return request.param
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
"""Integration tests for dynamic route page behavior."""
|
"""Integration tests for dynamic route page behavior."""
|
||||||
import time
|
from typing import Callable, Generator, Type
|
||||||
from typing import Generator
|
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from reflex.testing import AppHarness
|
from reflex import State
|
||||||
|
from reflex.testing import AppHarness, AppHarnessProd, WebDriver
|
||||||
|
|
||||||
from .utils import poll_for_navigation
|
from .utils import poll_for_navigation
|
||||||
|
|
||||||
@ -20,7 +20,14 @@ def DynamicRoute():
|
|||||||
page_id: str = ""
|
page_id: str = ""
|
||||||
|
|
||||||
def on_load(self):
|
def on_load(self):
|
||||||
self.order.append(self.page_id or "no page id")
|
self.order.append(
|
||||||
|
f"{self.get_current_page()}-{self.page_id or 'no page id'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_load_redir(self):
|
||||||
|
query_params = self.get_query_params()
|
||||||
|
self.order.append(f"on_load_redir-{query_params}")
|
||||||
|
return rx.redirect(f"/page/{query_params['page_id']}")
|
||||||
|
|
||||||
@rx.var
|
@rx.var
|
||||||
def next_page(self) -> str:
|
def next_page(self) -> str:
|
||||||
@ -42,37 +49,46 @@ def DynamicRoute():
|
|||||||
rx.link(
|
rx.link(
|
||||||
"next", href="/page/" + DynamicState.next_page, id="link_page_next" # type: ignore
|
"next", href="/page/" + DynamicState.next_page, id="link_page_next" # type: ignore
|
||||||
),
|
),
|
||||||
|
rx.link("missing", href="/missing", id="link_missing"),
|
||||||
rx.list(
|
rx.list(
|
||||||
rx.foreach(DynamicState.order, lambda i: rx.list_item(rx.text(i))), # type: ignore
|
rx.foreach(DynamicState.order, lambda i: rx.list_item(rx.text(i))), # type: ignore
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@rx.page(route="/redirect-page/[page_id]", on_load=DynamicState.on_load_redir) # type: ignore
|
||||||
|
def redirect_page():
|
||||||
|
return rx.fragment(rx.text("redirecting..."))
|
||||||
|
|
||||||
app = rx.App(state=DynamicState)
|
app = rx.App(state=DynamicState)
|
||||||
app.add_page(index)
|
app.add_page(index)
|
||||||
app.add_page(index, route="/page/[page_id]", on_load=DynamicState.on_load) # type: ignore
|
app.add_page(index, route="/page/[page_id]", on_load=DynamicState.on_load) # type: ignore
|
||||||
app.add_page(index, route="/static/x", on_load=DynamicState.on_load) # type: ignore
|
app.add_page(index, route="/static/x", on_load=DynamicState.on_load) # type: ignore
|
||||||
|
app.add_custom_404_page(on_load=DynamicState.on_load) # type: ignore
|
||||||
app.compile()
|
app.compile()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def dynamic_route(tmp_path_factory) -> Generator[AppHarness, None, None]:
|
def dynamic_route(
|
||||||
|
app_harness_env: Type[AppHarness], tmp_path_factory
|
||||||
|
) -> Generator[AppHarness, None, None]:
|
||||||
"""Start DynamicRoute app at tmp_path via AppHarness.
|
"""Start DynamicRoute app at tmp_path via AppHarness.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
app_harness_env: either AppHarness (dev) or AppHarnessProd (prod)
|
||||||
tmp_path_factory: pytest tmp_path_factory fixture
|
tmp_path_factory: pytest tmp_path_factory fixture
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
running AppHarness instance
|
running AppHarness instance
|
||||||
"""
|
"""
|
||||||
with AppHarness.create(
|
with app_harness_env.create(
|
||||||
root=tmp_path_factory.mktemp("dynamic_route"),
|
root=tmp_path_factory.mktemp(f"dynamic_route"),
|
||||||
app_source=DynamicRoute, # type: ignore
|
app_source=DynamicRoute, # type: ignore
|
||||||
) as harness:
|
) as harness:
|
||||||
yield harness
|
yield harness
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def driver(dynamic_route: AppHarness):
|
def driver(dynamic_route: AppHarness) -> Generator[WebDriver, None, None]:
|
||||||
"""Get an instance of the browser open to the dynamic_route app.
|
"""Get an instance of the browser open to the dynamic_route app.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -90,22 +106,70 @@ def driver(dynamic_route: AppHarness):
|
|||||||
driver.quit()
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
def test_on_load_navigate(dynamic_route: AppHarness, driver):
|
@pytest.fixture()
|
||||||
|
def backend_state(dynamic_route: AppHarness, driver: WebDriver) -> State:
|
||||||
|
"""Get the backend state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dynamic_route: harness for DynamicRoute app.
|
||||||
|
driver: WebDriver instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The backend state associated with the token visible in the driver browser.
|
||||||
|
"""
|
||||||
|
assert dynamic_route.app_instance is not None
|
||||||
|
token_input = driver.find_element(By.ID, "token")
|
||||||
|
assert token_input
|
||||||
|
|
||||||
|
# wait for the backend connection to send the token
|
||||||
|
token = dynamic_route.poll_for_value(token_input)
|
||||||
|
assert token is not None
|
||||||
|
|
||||||
|
# look up the backend state from the state manager
|
||||||
|
return dynamic_route.app_instance.state_manager.states[token]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def poll_for_order(
|
||||||
|
dynamic_route: AppHarness, backend_state: State
|
||||||
|
) -> Callable[[list[str]], None]:
|
||||||
|
"""Poll for the order list to match the expected order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dynamic_route: harness for DynamicRoute app.
|
||||||
|
backend_state: The backend state associated with the token visible in the driver browser.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A function that polls for the order list to match the expected order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _poll_for_order(exp_order: list[str]):
|
||||||
|
dynamic_route._poll_for(lambda: backend_state.order == exp_order)
|
||||||
|
assert backend_state.order == exp_order
|
||||||
|
|
||||||
|
return _poll_for_order
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_load_navigate(
|
||||||
|
dynamic_route: AppHarness,
|
||||||
|
driver: WebDriver,
|
||||||
|
backend_state: State,
|
||||||
|
poll_for_order: Callable[[list[str]], None],
|
||||||
|
):
|
||||||
"""Click links to navigate between dynamic pages with on_load event.
|
"""Click links to navigate between dynamic pages with on_load event.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dynamic_route: harness for DynamicRoute app.
|
dynamic_route: harness for DynamicRoute app.
|
||||||
driver: WebDriver instance.
|
driver: WebDriver instance.
|
||||||
|
backend_state: The backend state associated with the token visible in the driver browser.
|
||||||
|
poll_for_order: function that polls for the order list to match the expected order.
|
||||||
"""
|
"""
|
||||||
assert dynamic_route.app_instance is not None
|
assert dynamic_route.app_instance is not None
|
||||||
token_input = driver.find_element(By.ID, "token")
|
is_prod = isinstance(dynamic_route, AppHarnessProd)
|
||||||
link = driver.find_element(By.ID, "link_page_next")
|
link = driver.find_element(By.ID, "link_page_next")
|
||||||
assert token_input
|
|
||||||
assert link
|
assert link
|
||||||
|
|
||||||
# wait for the backend connection to send the token
|
exp_order = [f"/page/[page-id]-{ix}" for ix in range(10)]
|
||||||
token = dynamic_route.poll_for_value(token_input)
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# click the link a few times
|
# click the link a few times
|
||||||
for ix in range(10):
|
for ix in range(10):
|
||||||
@ -121,40 +185,84 @@ def test_on_load_navigate(dynamic_route: AppHarness, driver):
|
|||||||
assert page_id_input
|
assert page_id_input
|
||||||
|
|
||||||
assert dynamic_route.poll_for_value(page_id_input) == str(ix)
|
assert dynamic_route.poll_for_value(page_id_input) == str(ix)
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
# look up the backend state and assert that `on_load` was called for all
|
# manually load the next page to trigger client side routing in prod mode
|
||||||
# navigation events
|
if is_prod:
|
||||||
backend_state = dynamic_route.app_instance.state_manager.states[token]
|
exp_order += ["/404-no page id"]
|
||||||
time.sleep(0.2)
|
exp_order += ["/page/[page-id]-10"]
|
||||||
assert backend_state.order == [str(ix) for ix in range(10)]
|
with poll_for_navigation(driver):
|
||||||
|
driver.get(f"{dynamic_route.frontend_url}/page/10/")
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
|
# make sure internal nav still hydrates after redirect
|
||||||
|
exp_order += ["/page/[page-id]-11"]
|
||||||
|
link = driver.find_element(By.ID, "link_page_next")
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
link.click()
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
|
# load same page with a query param and make sure it passes through
|
||||||
|
if is_prod:
|
||||||
|
exp_order += ["/404-no page id"]
|
||||||
|
exp_order += ["/page/[page-id]-11"]
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
driver.get(f"{driver.current_url}?foo=bar")
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
assert backend_state.get_query_params()["foo"] == "bar"
|
||||||
|
|
||||||
|
# hit a 404 and ensure we still hydrate
|
||||||
|
exp_order += ["/404-no page id"]
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
driver.get(f"{dynamic_route.frontend_url}/missing")
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
|
# browser nav should still trigger hydration
|
||||||
|
if is_prod:
|
||||||
|
exp_order += ["/404-no page id"]
|
||||||
|
exp_order += ["/page/[page-id]-11"]
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
driver.back()
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
|
# next/link to a 404 and ensure we still hydrate
|
||||||
|
exp_order += ["/404-no page id"]
|
||||||
|
link = driver.find_element(By.ID, "link_missing")
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
link.click()
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
|
||||||
|
# hit a page that redirects back to dynamic page
|
||||||
|
if is_prod:
|
||||||
|
exp_order += ["/404-no page id"]
|
||||||
|
exp_order += ["on_load_redir-{'foo': 'bar', 'page_id': '0'}", "/page/[page-id]-0"]
|
||||||
|
with poll_for_navigation(driver):
|
||||||
|
driver.get(f"{dynamic_route.frontend_url}/redirect-page/0/?foo=bar")
|
||||||
|
poll_for_order(exp_order)
|
||||||
|
# should have redirected back to page 0
|
||||||
|
assert urlsplit(driver.current_url).path == "/page/0/"
|
||||||
|
|
||||||
|
|
||||||
def test_on_load_navigate_non_dynamic(dynamic_route: AppHarness, driver):
|
def test_on_load_navigate_non_dynamic(
|
||||||
|
dynamic_route: AppHarness,
|
||||||
|
driver: WebDriver,
|
||||||
|
poll_for_order: Callable[[list[str]], None],
|
||||||
|
):
|
||||||
"""Click links to navigate between static pages with on_load event.
|
"""Click links to navigate between static pages with on_load event.
|
||||||
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dynamic_route: harness for DynamicRoute app.
|
dynamic_route: harness for DynamicRoute app.
|
||||||
driver: WebDriver instance.
|
driver: WebDriver instance.
|
||||||
|
poll_for_order: function that polls for the order list to match the expected order.
|
||||||
"""
|
"""
|
||||||
assert dynamic_route.app_instance is not None
|
assert dynamic_route.app_instance is not None
|
||||||
token_input = driver.find_element(By.ID, "token")
|
|
||||||
link = driver.find_element(By.ID, "link_page_x")
|
link = driver.find_element(By.ID, "link_page_x")
|
||||||
assert token_input
|
|
||||||
assert link
|
assert link
|
||||||
|
|
||||||
# wait for the backend connection to send the token
|
|
||||||
token = dynamic_route.poll_for_value(token_input)
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
with poll_for_navigation(driver):
|
with poll_for_navigation(driver):
|
||||||
link.click()
|
link.click()
|
||||||
assert urlsplit(driver.current_url).path == "/static/x/"
|
assert urlsplit(driver.current_url).path == "/static/x/"
|
||||||
|
poll_for_order(["/static/x-no page id"])
|
||||||
# look up the backend state and assert that `on_load` was called once
|
|
||||||
backend_state = dynamic_route.app_instance.state_manager.states[token]
|
|
||||||
time.sleep(0.2)
|
|
||||||
assert backend_state.order == ["no page id"]
|
|
||||||
|
|
||||||
# go back to the index and navigate back to the static route
|
# go back to the index and navigate back to the static route
|
||||||
link = driver.find_element(By.ID, "link_index")
|
link = driver.find_element(By.ID, "link_index")
|
||||||
@ -166,5 +274,4 @@ def test_on_load_navigate_non_dynamic(dynamic_route: AppHarness, driver):
|
|||||||
with poll_for_navigation(driver):
|
with poll_for_navigation(driver):
|
||||||
link.click()
|
link.click()
|
||||||
assert urlsplit(driver.current_url).path == "/static/x/"
|
assert urlsplit(driver.current_url).path == "/static/x/"
|
||||||
time.sleep(0.2)
|
poll_for_order(["/static/x-no page id", "/static/x-no page id"])
|
||||||
assert backend_state.order == ["no page id", "no page id"]
|
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
import Router from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function Custom404() {
|
|
||||||
const [isNotFound, setIsNotFound] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pathNameArray = window.location.pathname.split("/");
|
|
||||||
if (pathNameArray.length == 2 && pathNameArray[1] == "404") {
|
|
||||||
setIsNotFound(true);
|
|
||||||
} else {
|
|
||||||
Router.replace(window.location.pathname);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isNotFound) return <h1>404 - Page Not Found</h1>;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
36
reflex/.templates/web/utils/client_side_routing.js
Normal file
36
reflex/.templates/web/utils/client_side_routing.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for use in /404 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).
|
||||||
|
*
|
||||||
|
* @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()
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
router.isReady &&
|
||||||
|
!didRedirect.current // have not tried redirecting yet
|
||||||
|
) {
|
||||||
|
didRedirect.current = true // never redirect twice to avoid "Hard Navigate" error
|
||||||
|
// attempt to redirect to the route in the browser address bar once
|
||||||
|
router.replace({
|
||||||
|
pathname: window.location.pathname,
|
||||||
|
query: window.location.search.slice(1),
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setRouteNotFound(true) // navigation failed, so this is a real 404
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [router.isReady]);
|
||||||
|
|
||||||
|
// Return the reactive bool, to avoid flashing 404 page until we know for sure
|
||||||
|
// the route is not found.
|
||||||
|
return routeNotFound
|
||||||
|
}
|
@ -173,10 +173,13 @@ export const applyEvent = async (event, socket) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the event to the server.
|
// Update token and router data (if missing).
|
||||||
event.token = getToken();
|
event.token = getToken()
|
||||||
event.router_data = (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(Router);
|
if (event.router_data === undefined || Object.keys(event.router_data).length === 0) {
|
||||||
|
event.router_data = (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(Router)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the event to the server.
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit("event", JSON.stringify(event));
|
socket.emit("event", JSON.stringify(event));
|
||||||
return true;
|
return true;
|
||||||
@ -255,7 +258,6 @@ export const processEvent = async (
|
|||||||
* @param dispatch The function to queue state update
|
* @param dispatch The function to queue state update
|
||||||
* @param transports The transports to use.
|
* @param transports The transports to use.
|
||||||
* @param setConnectError The function to update connection error value.
|
* @param setConnectError The function to update connection error value.
|
||||||
* @param initial_events Array of events to seed the queue after connecting.
|
|
||||||
* @param client_storage The client storage object from context.js
|
* @param client_storage The client storage object from context.js
|
||||||
*/
|
*/
|
||||||
export const connect = async (
|
export const connect = async (
|
||||||
@ -263,7 +265,6 @@ export const connect = async (
|
|||||||
dispatch,
|
dispatch,
|
||||||
transports,
|
transports,
|
||||||
setConnectError,
|
setConnectError,
|
||||||
initial_events = [],
|
|
||||||
client_storage = {},
|
client_storage = {},
|
||||||
) => {
|
) => {
|
||||||
// Get backend URL object from the endpoint.
|
// Get backend URL object from the endpoint.
|
||||||
@ -277,7 +278,6 @@ export const connect = async (
|
|||||||
|
|
||||||
// Once the socket is open, hydrate the page.
|
// Once the socket is open, hydrate the page.
|
||||||
socket.current.on("connect", () => {
|
socket.current.on("connect", () => {
|
||||||
queueEvents(initial_events, socket)
|
|
||||||
setConnectError(null)
|
setConnectError(null)
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -427,8 +427,8 @@ const applyClientStorageDelta = (client_storage, delta) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Establish websocket event loop for a NextJS page.
|
* Establish websocket event loop for a NextJS page.
|
||||||
* @param initial_state The initial page state.
|
* @param initial_state The initial app state.
|
||||||
* @param initial_events Array of events to seed the queue after connecting.
|
* @param initial_events The initial app events.
|
||||||
* @param client_storage The client storage object from context.js
|
* @param client_storage The client storage object from context.js
|
||||||
*
|
*
|
||||||
* @returns [state, Event, connectError] -
|
* @returns [state, Event, connectError] -
|
||||||
@ -452,6 +452,15 @@ export const useEventLoop = (
|
|||||||
queueEvents(events, socket)
|
queueEvents(events, socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
|
||||||
|
// initial state hydrate
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady && !sentHydrate.current) {
|
||||||
|
Event(initial_events.map((e) => ({...e})))
|
||||||
|
sentHydrate.current = true
|
||||||
|
}
|
||||||
|
}, [router.isReady])
|
||||||
|
|
||||||
// Main event loop.
|
// Main event loop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if the router is not ready.
|
// Skip if the router is not ready.
|
||||||
@ -461,7 +470,7 @@ export const useEventLoop = (
|
|||||||
|
|
||||||
// Initialize the websocket connection.
|
// Initialize the websocket connection.
|
||||||
if (!socket.current) {
|
if (!socket.current) {
|
||||||
connect(socket, dispatch, ['websocket', 'polling'], setConnectError, initial_events, client_storage)
|
connect(socket, dispatch, ['websocket', 'polling'], setConnectError, client_storage)
|
||||||
}
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
// Process all outstanding events.
|
// Process all outstanding events.
|
||||||
|
@ -32,6 +32,10 @@ from reflex.compiler import utils as compiler_utils
|
|||||||
from reflex.components import connection_modal
|
from reflex.components import connection_modal
|
||||||
from reflex.components.component import Component, ComponentStyle
|
from reflex.components.component import Component, ComponentStyle
|
||||||
from reflex.components.layout.fragment import Fragment
|
from reflex.components.layout.fragment import Fragment
|
||||||
|
from reflex.components.navigation.client_side_routing import (
|
||||||
|
Default404Page,
|
||||||
|
wait_for_client_redirect,
|
||||||
|
)
|
||||||
from reflex.config import get_config
|
from reflex.config import get_config
|
||||||
from reflex.event import Event, EventHandler, EventSpec
|
from reflex.event import Event, EventHandler, EventSpec
|
||||||
from reflex.middleware import HydrateMiddleware, Middleware
|
from reflex.middleware import HydrateMiddleware, Middleware
|
||||||
@ -451,8 +455,10 @@ class App(Base):
|
|||||||
on_load: The event handler(s) that will be called each time the page load.
|
on_load: The event handler(s) that will be called each time the page load.
|
||||||
meta: The metadata of the page.
|
meta: The metadata of the page.
|
||||||
"""
|
"""
|
||||||
|
if component is None:
|
||||||
|
component = Default404Page.create()
|
||||||
self.add_page(
|
self.add_page(
|
||||||
component=component if component else Fragment.create(),
|
component=wait_for_client_redirect(self._generate_component(component)),
|
||||||
route=constants.SLUG_404,
|
route=constants.SLUG_404,
|
||||||
title=title or constants.TITLE_404,
|
title=title or constants.TITLE_404,
|
||||||
image=image or constants.FAVICON_404,
|
image=image or constants.FAVICON_404,
|
||||||
@ -533,6 +539,10 @@ class App(Base):
|
|||||||
for render, kwargs in DECORATED_PAGES:
|
for render, kwargs in DECORATED_PAGES:
|
||||||
self.add_page(render, **kwargs)
|
self.add_page(render, **kwargs)
|
||||||
|
|
||||||
|
# Render a default 404 page if the user didn't supply one
|
||||||
|
if constants.SLUG_404 not in self.pages:
|
||||||
|
self.add_custom_404_page()
|
||||||
|
|
||||||
task = progress.add_task("Compiling: ", total=len(self.pages))
|
task = progress.add_task("Compiling: ", total=len(self.pages))
|
||||||
# TODO: include all work done in progress indicator, not just self.pages
|
# TODO: include all work done in progress indicator, not just self.pages
|
||||||
|
|
||||||
|
@ -276,5 +276,5 @@ def compile_tailwind(
|
|||||||
|
|
||||||
def purge_web_pages_dir():
|
def purge_web_pages_dir():
|
||||||
"""Empty out .web directory."""
|
"""Empty out .web directory."""
|
||||||
template_files = ["_app.js", "404.js"]
|
template_files = ["_app.js"]
|
||||||
utils.empty_dir(constants.WEB_PAGES_DIR, keep_files=template_files)
|
utils.empty_dir(constants.WEB_PAGES_DIR, keep_files=template_files)
|
||||||
|
69
reflex/components/navigation/client_side_routing.py
Normal file
69
reflex/components/navigation/client_side_routing.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Handle dynamic routes in static exports via client-side routing.
|
||||||
|
|
||||||
|
Works with /utils/client_side_routing.js to handle the redirect and state.
|
||||||
|
|
||||||
|
When the user hits a 404 accessing a route, redirect them to the same page,
|
||||||
|
setting a reactive state var "routeNotFound" to true if the redirect fails. The
|
||||||
|
`wait_for_client_redirect` function will render the component only after
|
||||||
|
routeNotFound becomes true.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from reflex import constants
|
||||||
|
|
||||||
|
from ...vars import Var
|
||||||
|
from ..component import Component
|
||||||
|
from ..layout.cond import Cond
|
||||||
|
|
||||||
|
route_not_found = Var.create_safe(constants.ROUTE_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientSideRouting(Component):
|
||||||
|
"""The client-side routing component."""
|
||||||
|
|
||||||
|
library = "/utils/client_side_routing"
|
||||||
|
tag = "useClientSideRouting"
|
||||||
|
|
||||||
|
def _get_hooks(self) -> str:
|
||||||
|
"""Get the hooks to render.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The useClientSideRouting hook.
|
||||||
|
"""
|
||||||
|
return f"const {constants.ROUTE_NOT_FOUND} = {self.tag}()"
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
"""Render the component.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty string, because this component is only used for its hooks.
|
||||||
|
"""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_client_redirect(component) -> Component:
|
||||||
|
"""Wait for a redirect to occur before rendering a component.
|
||||||
|
|
||||||
|
This prevents the 404 page from flashing while the redirect is happening.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
component: The component to render after the redirect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The conditionally rendered component.
|
||||||
|
"""
|
||||||
|
return Cond.create(
|
||||||
|
cond=route_not_found,
|
||||||
|
comp1=component,
|
||||||
|
comp2=ClientSideRouting.create(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Default404Page(Component):
|
||||||
|
"""The NextJS default 404 page."""
|
||||||
|
|
||||||
|
library = "next/error"
|
||||||
|
tag = "Error"
|
||||||
|
is_default = True
|
||||||
|
|
||||||
|
status_code: Var[int] = 404 # type: ignore
|
@ -344,6 +344,7 @@ SLUG_404 = "404"
|
|||||||
TITLE_404 = "404 - Not Found"
|
TITLE_404 = "404 - Not Found"
|
||||||
FAVICON_404 = "favicon.ico"
|
FAVICON_404 = "favicon.ico"
|
||||||
DESCRIPTION_404 = "The page was not found"
|
DESCRIPTION_404 = "The page was not found"
|
||||||
|
ROUTE_NOT_FOUND = "routeNotFound"
|
||||||
|
|
||||||
# Color mode variables
|
# Color mode variables
|
||||||
USE_COLOR_MODE = "useColorMode"
|
USE_COLOR_MODE = "useColorMode"
|
||||||
|
@ -449,12 +449,17 @@ def get_handler_args(event_spec: EventSpec, arg: Var) -> tuple[tuple[Var, Var],
|
|||||||
return event_spec.args if len(args) > 1 else tuple()
|
return event_spec.args if len(args) > 1 else tuple()
|
||||||
|
|
||||||
|
|
||||||
def fix_events(events: list[EventHandler | EventSpec], token: str) -> list[Event]:
|
def fix_events(
|
||||||
|
events: list[EventHandler | EventSpec],
|
||||||
|
token: str,
|
||||||
|
router_data: dict[str, Any] | None = None,
|
||||||
|
) -> list[Event]:
|
||||||
"""Fix a list of events returned by an event handler.
|
"""Fix a list of events returned by an event handler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
events: The events to fix.
|
events: The events to fix.
|
||||||
token: The user token.
|
token: The user token.
|
||||||
|
router_data: The optional router data to set in the event.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The fixed events.
|
The fixed events.
|
||||||
@ -485,6 +490,7 @@ def fix_events(events: list[EventHandler | EventSpec], token: str) -> list[Event
|
|||||||
token=token,
|
token=token,
|
||||||
name=name,
|
name=name,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
router_data=router_data or {},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ class HydrateMiddleware(Middleware):
|
|||||||
|
|
||||||
# Add the on_load events and set is_hydrated to True.
|
# Add the on_load events and set is_hydrated to True.
|
||||||
events = [*app.get_load_events(route), type(state).set_is_hydrated(True)] # type: ignore
|
events = [*app.get_load_events(route), type(state).set_is_hydrated(True)] # type: ignore
|
||||||
events = fix_events(events, event.token)
|
events = fix_events(events, event.token, router_data=event.router_data)
|
||||||
|
|
||||||
# Return the state update.
|
# Return the state update.
|
||||||
return StateUpdate(delta=delta, events=events)
|
return StateUpdate(delta=delta, events=events)
|
||||||
|
@ -10,11 +10,13 @@ import platform
|
|||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
|
import socketserver
|
||||||
import subprocess
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import types
|
import types
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
@ -156,9 +158,9 @@ class AppHarness:
|
|||||||
)
|
)
|
||||||
self.app_module_path.write_text(source_code)
|
self.app_module_path.write_text(source_code)
|
||||||
with chdir(self.app_path):
|
with chdir(self.app_path):
|
||||||
# ensure config is reloaded when testing different app
|
# ensure config and app are reloaded when testing different app
|
||||||
reflex.config.get_config(reload=True)
|
reflex.config.get_config(reload=True)
|
||||||
self.app_module = reflex.utils.prerequisites.get_app()
|
self.app_module = reflex.utils.prerequisites.get_app(reload=True)
|
||||||
self.app_instance = self.app_module.app
|
self.app_instance = self.app_module.app
|
||||||
|
|
||||||
def _start_backend(self):
|
def _start_backend(self):
|
||||||
@ -461,3 +463,161 @@ class AppHarness:
|
|||||||
):
|
):
|
||||||
raise TimeoutError("No states were observed while polling.")
|
raise TimeoutError("No states were observed while polling.")
|
||||||
return state_manager.states
|
return state_manager.states
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleHTTPRequestHandlerCustomErrors(SimpleHTTPRequestHandler):
|
||||||
|
"""SimpleHTTPRequestHandler with custom error page handling."""
|
||||||
|
|
||||||
|
def __init__(self, *args, error_page_map: dict[int, pathlib.Path], **kwargs):
|
||||||
|
"""Initialize the handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_page_map: map of error code to error page path
|
||||||
|
*args: passed through to superclass
|
||||||
|
**kwargs: passed through to superclass
|
||||||
|
"""
|
||||||
|
self.error_page_map = error_page_map
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def send_error(
|
||||||
|
self, code: int, message: str | None = None, explain: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Send the error page for the given error code.
|
||||||
|
|
||||||
|
If the code matches a custom error page, then message and explain are
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: the error code
|
||||||
|
message: the error message
|
||||||
|
explain: the error explanation
|
||||||
|
"""
|
||||||
|
error_page = self.error_page_map.get(code)
|
||||||
|
if error_page:
|
||||||
|
self.send_response(code, message)
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
body = error_page.read_bytes()
|
||||||
|
self.send_header("Content-Type", self.error_content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
else:
|
||||||
|
super().send_error(code, message, explain)
|
||||||
|
|
||||||
|
|
||||||
|
class Subdir404TCPServer(socketserver.TCPServer):
|
||||||
|
"""TCPServer for SimpleHTTPRequestHandlerCustomErrors that serves from a subdir."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
root: pathlib.Path,
|
||||||
|
error_page_map: dict[int, pathlib.Path] | None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: the root directory to serve from
|
||||||
|
error_page_map: map of error code to error page path
|
||||||
|
*args: passed through to superclass
|
||||||
|
**kwargs: passed through to superclass
|
||||||
|
"""
|
||||||
|
self.root = root
|
||||||
|
self.error_page_map = error_page_map or {}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def finish_request(self, request: socket.socket, client_address: tuple[str, int]):
|
||||||
|
"""Finish one request by instantiating RequestHandlerClass.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: the requesting socket
|
||||||
|
client_address: (host, port) referring to the client’s address.
|
||||||
|
"""
|
||||||
|
print(client_address, type(client_address))
|
||||||
|
self.RequestHandlerClass(
|
||||||
|
request,
|
||||||
|
client_address,
|
||||||
|
self,
|
||||||
|
directory=str(self.root), # type: ignore
|
||||||
|
error_page_map=self.error_page_map, # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppHarnessProd(AppHarness):
|
||||||
|
"""AppHarnessProd executes a reflex app in-process for testing.
|
||||||
|
|
||||||
|
In prod mode, instead of running `next dev` the app is exported as static
|
||||||
|
files and served via the builtin python http.server with custom 404 redirect
|
||||||
|
handling. Additionally, the backend runs in multi-worker mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
frontend_thread: Optional[threading.Thread] = None
|
||||||
|
frontend_server: Optional[Subdir404TCPServer] = None
|
||||||
|
|
||||||
|
def _run_frontend(self):
|
||||||
|
web_root = self.app_path / reflex.constants.WEB_DIR / "_static"
|
||||||
|
error_page_map = {
|
||||||
|
404: web_root / "404.html",
|
||||||
|
}
|
||||||
|
with Subdir404TCPServer(
|
||||||
|
("", 0),
|
||||||
|
SimpleHTTPRequestHandlerCustomErrors,
|
||||||
|
root=web_root,
|
||||||
|
error_page_map=error_page_map,
|
||||||
|
) as self.frontend_server:
|
||||||
|
self.frontend_url = "http://localhost:{1}".format(
|
||||||
|
*self.frontend_server.socket.getsockname()
|
||||||
|
)
|
||||||
|
self.frontend_server.serve_forever()
|
||||||
|
|
||||||
|
def _start_frontend(self):
|
||||||
|
# Set up the frontend.
|
||||||
|
with chdir(self.app_path):
|
||||||
|
config = reflex.config.get_config()
|
||||||
|
config.api_url = "http://{0}:{1}".format(
|
||||||
|
*self._poll_for_servers().getsockname(),
|
||||||
|
)
|
||||||
|
reflex.reflex.export(
|
||||||
|
zipping=False,
|
||||||
|
frontend=True,
|
||||||
|
backend=False,
|
||||||
|
loglevel=reflex.constants.LogLevel.INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.frontend_thread = threading.Thread(target=self._run_frontend)
|
||||||
|
self.frontend_thread.start()
|
||||||
|
|
||||||
|
def _wait_frontend(self):
|
||||||
|
self._poll_for(lambda: self.frontend_server is not None)
|
||||||
|
if self.frontend_server is None or not self.frontend_server.socket.fileno():
|
||||||
|
raise RuntimeError("Frontend did not start")
|
||||||
|
|
||||||
|
def _start_backend(self):
|
||||||
|
if self.app_instance is None:
|
||||||
|
raise RuntimeError("App was not initialized.")
|
||||||
|
os.environ[reflex.constants.SKIP_COMPILE_ENV_VAR] = "yes"
|
||||||
|
self.backend = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
app=self.app_instance,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=0,
|
||||||
|
workers=reflex.utils.processes.get_num_workers(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.backend_thread = threading.Thread(target=self.backend.run)
|
||||||
|
self.backend_thread.start()
|
||||||
|
|
||||||
|
def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket:
|
||||||
|
try:
|
||||||
|
return super()._poll_for_servers(timeout)
|
||||||
|
finally:
|
||||||
|
os.environ.pop(reflex.constants.SKIP_COMPILE_ENV_VAR, None)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the frontend python webserver."""
|
||||||
|
super().stop()
|
||||||
|
if self.frontend_server is not None:
|
||||||
|
self.frontend_server.shutdown()
|
||||||
|
if self.frontend_thread is not None:
|
||||||
|
self.frontend_thread.join()
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@ -97,16 +98,22 @@ def get_package_manager() -> str | None:
|
|||||||
return path_ops.get_npm_path()
|
return path_ops.get_npm_path()
|
||||||
|
|
||||||
|
|
||||||
def get_app() -> ModuleType:
|
def get_app(reload: bool = False) -> ModuleType:
|
||||||
"""Get the app module based on the default config.
|
"""Get the app module based on the default config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reload: Re-import the app module from disk
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The app based on the default config.
|
The app based on the default config.
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
module = ".".join([config.app_name, config.app_name])
|
module = ".".join([config.app_name, config.app_name])
|
||||||
sys.path.insert(0, os.getcwd())
|
sys.path.insert(0, os.getcwd())
|
||||||
return __import__(module, fromlist=(constants.APP_VAR,))
|
app = __import__(module, fromlist=(constants.APP_VAR,))
|
||||||
|
if reload:
|
||||||
|
importlib.reload(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> Redis | None:
|
def get_redis() -> Redis | None:
|
||||||
|
@ -809,9 +809,17 @@ async def test_dynamic_route_var_route_change_completed_on_load(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for exp_index, exp_val in enumerate(exp_vals):
|
for exp_index, exp_val in enumerate(exp_vals):
|
||||||
|
hydrate_event = _event(name=get_hydrate_event(state), val=exp_val)
|
||||||
|
exp_router_data = {
|
||||||
|
"headers": {},
|
||||||
|
"ip": client_ip,
|
||||||
|
"sid": sid,
|
||||||
|
"token": token,
|
||||||
|
**hydrate_event.router_data,
|
||||||
|
}
|
||||||
update = await process(
|
update = await process(
|
||||||
app,
|
app,
|
||||||
event=_event(name=get_hydrate_event(state), val=exp_val),
|
event=hydrate_event,
|
||||||
sid=sid,
|
sid=sid,
|
||||||
headers={},
|
headers={},
|
||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
@ -830,12 +838,16 @@ async def test_dynamic_route_var_route_change_completed_on_load(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
events=[
|
events=[
|
||||||
_dynamic_state_event(name="on_load", val=exp_val, router_data={}),
|
_dynamic_state_event(
|
||||||
|
name="on_load",
|
||||||
|
val=exp_val,
|
||||||
|
router_data=exp_router_data,
|
||||||
|
),
|
||||||
_dynamic_state_event(
|
_dynamic_state_event(
|
||||||
name="set_is_hydrated",
|
name="set_is_hydrated",
|
||||||
payload={"value": True},
|
payload={"value": True},
|
||||||
val=exp_val,
|
val=exp_val,
|
||||||
router_data={},
|
router_data=exp_router_data,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user