diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 1f218240cb5..e02b3bf921f 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -168,6 +168,10 @@ jobs: working-directory: ./docs/app run: uv run --active --no-sync pytest --runxfail tests/test_doc_links.py -v + - name: Run docs frontend regression tests + working-directory: ./docs/app + run: uv run --active --no-sync node --test tests/frontend_quality.test.mjs + - name: Upload Socket.dev Firewall report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/advanced_onboarding/code_structure.md b/docs/advanced_onboarding/code_structure.md index 025f79837d7..2e6f103a60d 100644 --- a/docs/advanced_onboarding/code_structure.md +++ b/docs/advanced_onboarding/code_structure.md @@ -91,7 +91,7 @@ def template(page: Callable[[], rx.Component]) -> rx.Component: ) ``` -The `@template` decorator should appear below the `@rx.page` decorator and above the page-returning function. See the [Posts Page](#a-post-page-example_big_apppagespostspy) code for an example. +The `@template` decorator should appear below the `@rx.page` decorator and above the page-returning function. See the [Posts Page](#a-post-page:-example_big_app/pages/posts.py) code for an example. ## State Management diff --git a/docs/app/README.md b/docs/app/README.md index 6fbaa8022ba..95f3d5eac42 100644 --- a/docs/app/README.md +++ b/docs/app/README.md @@ -40,3 +40,25 @@ WHITELISTED_PAGES = [ - Paths are prefix-matched, so `"/components"` will include all pages under that section. After editing the whitelist, restart the dev server for changes to take effect. + +## Production quality checks + +Build the complete documentation app before auditing SEO or load performance: + +```bash +uv run reflex export --no-zip +node --test tests/frontend_quality.test.mjs +uv run pytest tests +``` + +If the build uses a custom `REFLEX_WEB_WORKDIR`, pass that environment variable to both test commands. The Python link validator reads that build's sitemap. The frontend tests use the build's installed React and bundler to check server-rendered code, highlight invalidation, and removal of unused components. + +The `reflex-docs` integration CI jobs run the frontend tests after building the production site, using the installed React and bundler dependencies. + +Breadcrumbs and canonical URLs use `deploy_url` and `frontend_path` from the app config. Local runs use the framework's localhost default. Deployment jobs must set `REFLEX_DEPLOY_URL` to the origin serving that build (for example, `https://reflex.dev` in production or the staging origin). + +The docs app serves permanent HTTP 301 redirects for its legacy URLs when the Reflex backend serves the frontend. In development or when HTML is hosted separately, requests reach the frontend instead: the redirect pages retain client navigation and prerendered HTML includes an immediate refresh, canonical link, noindex directive, and a usable destination link. That fallback navigates readers but returns HTTP 200. For HTTP 301 semantics on a separate frontend/CDN, configure redirects at that host's edge using the `redirects` list in `reflex_docs/reflex_docs.py`; backend middleware alone cannot redirect requests it never receives. + +Docs pages intentionally omit the marketing site's pixels and session recording scripts. Search, examples, newsletter signup, and status information remain available. + +The docs config enables `frontend_lazy_bundled_libraries`. Optional libraries registered for dynamic components load on the first dynamic-component evaluation, while React and the shared runtime stay available immediately. This prevents the full Radix namespace from being imported on every page. The framework default remains `False`; custom scripts that read optional libraries from `window.__reflex` directly should retain that default or await `window.__reflex_load()` first. diff --git a/docs/app/news/+docs-search-and-loading.docs.md b/docs/app/news/+docs-search-and-loading.docs.md new file mode 100644 index 00000000000..fff52134485 --- /dev/null +++ b/docs/app/news/+docs-search-and-loading.docs.md @@ -0,0 +1 @@ +Improve documentation search metadata, structured breadcrumbs, legacy redirects, responsive reference tables, and accessible examples. Remove marketing trackers from documentation pages. diff --git a/docs/app/reflex_docs/pages/docs/__init__.py b/docs/app/reflex_docs/pages/docs/__init__.py index 800374cdd6b..40c6b86fafb 100644 --- a/docs/app/reflex_docs/pages/docs/__init__.py +++ b/docs/app/reflex_docs/pages/docs/__init__.py @@ -179,6 +179,8 @@ def get_image_from_frontmatter(filepath: str) -> str | None: manual_titles = { + "docs/ai_builder/apis.md": "APIs", + "docs/ai_builder/urls.md": "URLs", "docs/database/overview.md": "Database Overview", "docs/custom-components/overview.md": "Custom Components Overview", "docs/custom-components/command-reference.md": "Custom Component CLI Reference", @@ -268,7 +270,7 @@ def extract_doc_description( Returns: A cleaned, truncated description, or None. """ - min_len = 120 + min_len = 40 if metadata: for key in ("meta_description", "description"): value = metadata.get(key) @@ -316,8 +318,8 @@ def extract_doc_description( *(f"{n}." for n in range(1, 10)), ) # Accumulate prose across paragraph breaks until the description is - # substantial (~120 chars) so a short opening sentence doesn't become a - # too-short meta description. Stop at the first structural line + # a useful summary (~40 chars) so a short opening sentence doesn't become a + # generic meta description. Stop at the first structural line # (heading/list/code) once some prose has been collected. for raw in text.splitlines(): line = raw.strip() diff --git a/docs/app/reflex_docs/pages/docs/metadata.py b/docs/app/reflex_docs/pages/docs/metadata.py index 538d019e0da..401ab5b1dc3 100644 --- a/docs/app/reflex_docs/pages/docs/metadata.py +++ b/docs/app/reflex_docs/pages/docs/metadata.py @@ -19,3 +19,44 @@ def truncate_meta_description(description: str, max_len: int = 155) -> str: # exceeds max_len, even when the leading slice has no word boundary. truncated = description[: max_len - 1].rsplit(" ", 1)[0].rstrip(",.;:") return f"{truncated}…" + + +def docs_metadata(path: str, title: str, description: str | None) -> tuple[str, str]: + """Build distinct search snippets using the page's product hierarchy. + + Args: + path: The app-relative documentation route. + title: The page heading. + description: A summary extracted from the document, when available. + + Returns: + The search title and description. + """ + acronyms = { + "ai": "AI", + "api": "API", + "cli": "CLI", + "html": "HTML", + "css": "CSS", + "mcp": "MCP", + "sdk": "SDK", + } + parents = [ + " ".join(acronyms.get(word, word.capitalize()) for word in part.split("-")) + for part in path.strip("/").split("/")[:-1] + ] + if title.lower() == "index" and parents: + title = " ".join( + acronyms.get(word, word.capitalize()) + for word in path.strip("/").split("/")[-1].split("-") + ) + title = " ".join(acronyms.get(word.lower(), word) for word in title.split()) + context = [ + parent for parent in reversed(parents) if parent.lower() != title.lower() + ] + subject = " · ".join([title, *context]) + summary = ( + description + or f"{subject}: documentation, examples, and reference for building Python web applications with Reflex." + ) + return f"{subject} · Reflex Docs", truncate_meta_description(summary) diff --git a/docs/app/reflex_docs/pages/docs/source.py b/docs/app/reflex_docs/pages/docs/source.py index 27b0916f932..3178d77d8ad 100644 --- a/docs/app/reflex_docs/pages/docs/source.py +++ b/docs/app/reflex_docs/pages/docs/source.py @@ -30,49 +30,39 @@ def stacked_description_rows( description: Callable[[], rx.Component], breakpoint: str, description_cell_class: str = "", -) -> tuple[rx.Component, rx.Component]: +) -> tuple[rx.Component, ...]: """Build a table row whose description column stacks on narrow containers. On containers at least as wide as the Tailwind ``breakpoint``, the description renders as the last cell of a single row. Below that, it - drops to a second full-width row so the table never needs horizontal + stacks below the other cells so the table never needs horizontal scrolling just to read descriptions. The nearest ancestor with the ``@container`` class defines the measured width. Args: leading_cells: The non-description cells as (content, extra cell classes). - description: Description factory, called once per rendered copy. + description: Description factory, called once. breakpoint: Tailwind container breakpoint (e.g. ``"4xl"``) above which the description stays in-row. description_cell_class: Extra classes for the in-row description cell. Returns: - The main row and the narrow-only description row. + A single row whose description spans the grid on narrow containers. """ return ( rx.table.row( *[ rx.table.cell( content, - # When stacked, the description row below carries the divider. - class_name=f"{extra_class} @max-{breakpoint}:shadow-none".strip(), + class_name=f"{extra_class} @max-{breakpoint}:shadow-none @max-{breakpoint}:min-w-0 @max-{breakpoint}:h-auto".strip(), ) for content, extra_class in leading_cells - ] - + [ - rx.table.cell( - description(), - class_name=f"{description_cell_class_name} {description_cell_class} hidden @{breakpoint}:table-cell", - ), - ] - ), - rx.table.row( + ], rx.table.cell( description(), - col_span=len(leading_cells), - class_name=description_cell_class_name, + class_name=f"{description_cell_class_name} {description_cell_class} @max-{breakpoint}:col-span-full @max-{breakpoint}:h-auto [&_p:last-child]:mb-0", ), - class_name=f"@{breakpoint}:hidden", + class_name=f"@max-{breakpoint}:grid @max-{breakpoint}:grid-cols-{len(leading_cells)}", ), ) @@ -119,7 +109,7 @@ def format_fields( if env_var_prefix is not None: headers = [headers[0], "Environment Variable", *headers[1:]] - def field_rows(field: FieldDocumentation) -> tuple[rx.Component, rx.Component]: + def field_rows(field: FieldDocumentation) -> tuple[rx.Component, ...]: leading_cells = [(format_field(field), "")] if env_var_prefix is not None: leading_cells.append(( diff --git a/docs/app/reflex_docs/pages/docs_landing/views/ai_builder.py b/docs/app/reflex_docs/pages/docs_landing/views/ai_builder.py index caed0932ec5..6339dded631 100644 --- a/docs/app/reflex_docs/pages/docs_landing/views/ai_builder.py +++ b/docs/app/reflex_docs/pages/docs_landing/views/ai_builder.py @@ -88,7 +88,11 @@ def card( class_name="flex flex-col gap-2 p-8", ), content, - rx.el.a(href=href, class_name="absolute inset-0"), + rx.el.a( + href=href, + aria_label=title, + class_name="absolute inset-0 rounded-xl focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-primary-9", + ), class_name="flex flex-col bg-secondary-1/96 backdrop-blur-[16px] rounded-xl relative cursor-pointer transition-colors overflow-hidden shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_12px_24px_0_rgba(0,0,0,0.08),0_1px_1px_0_rgba(0,0,0,0.01),0_4px_8px_0_rgba(0,0,0,0.03)] dark:shadow-none dark:border dark:border-secondary-4", ) diff --git a/docs/app/reflex_docs/pages/docs_landing/views/link_item.py b/docs/app/reflex_docs/pages/docs_landing/views/link_item.py index 6097a3dddd2..41fbfb6adfd 100644 --- a/docs/app/reflex_docs/pages/docs_landing/views/link_item.py +++ b/docs/app/reflex_docs/pages/docs_landing/views/link_item.py @@ -43,7 +43,11 @@ def link_item( description, class_name="text-secondary-11 text-sm font-[475] text-start", ), - rx.el.a(to=href, class_name="absolute inset-0"), + rx.el.a( + to=href, + aria_label=title, + class_name="absolute inset-0 rounded-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary-9", + ), class_name=ui.cn( "flex flex-col gap-2 pr-8 py-8 group border-r border-b border-secondary-4 relative max-lg:p-6 hover:bg-[linear-gradient(243deg,var(--secondary-2)_0%,var(--secondary-1)_100%)]", "lg:pl-8 pl-6" if has_padding_left else "", diff --git a/docs/app/reflex_docs/redirects.py b/docs/app/reflex_docs/redirects.py new file mode 100644 index 00000000000..32741185a4b --- /dev/null +++ b/docs/app/reflex_docs/redirects.py @@ -0,0 +1,46 @@ +"""Permanent redirects for renamed documentation pages.""" + +from collections.abc import Sequence + +from starlette.responses import RedirectResponse +from starlette.types import ASGIApp, Receive, Scope, Send + + +class DocsRedirectMiddleware: + """Resolve legacy documentation URLs before rendering the frontend.""" + + def __init__( + self, + app: ASGIApp, + redirects: Sequence[tuple[str, str]], + frontend_path: str, + ) -> None: + """Build a lookup of public legacy URLs and their canonical destinations. + + Args: + app: The wrapped application. + redirects: App-relative source and destination routes. + frontend_path: The public mount prefix. + """ + self.app = app + prefix = frontend_path.rstrip("/") + self.redirects = { + prefix + source.rstrip("/"): prefix + target for source, target in redirects + } + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Redirect known GET/HEAD routes and pass other requests through. + + Args: + scope: The ASGI connection scope. + receive: The ASGI receive callable. + send: The ASGI send callable. + """ + if scope["type"] == "http" and scope["method"] in {"GET", "HEAD"}: + target = self.redirects.get(scope["path"].rstrip("/")) + if target is not None: + if query := scope.get("query_string", b""): + target += "?" + query.decode("latin-1") + await RedirectResponse(target, status_code=301)(scope, receive, send) + return + await self.app(scope, receive, send) diff --git a/docs/app/reflex_docs/reflex_docs.py b/docs/app/reflex_docs/reflex_docs.py index 2b0fde9d317..b58f3adac72 100644 --- a/docs/app/reflex_docs/reflex_docs.py +++ b/docs/app/reflex_docs/reflex_docs.py @@ -1,22 +1,26 @@ """The main Reflex website.""" +import json import os import sys +from functools import partial import reflex as rx import reflex_enterprise as rxe from reflex_site_shared import styles from reflex_site_shared.backend.status import monitor_checkly_status -from reflex_site_shared.constants import REFLEX_ASSETS_CDN, REFLEX_DOMAIN_URL +from reflex_site_shared.constants import REFLEX_ASSETS_CDN from reflex_site_shared.meta.meta import ( ONE_LINE_DESCRIPTION, create_meta_tags, favicons_links, to_cdn_image_url, ) -from reflex_site_shared.telemetry import get_pixel_website_trackers +from reflex_site_shared.utils.url import public_url from reflex_docs.pages import page404, routes +from reflex_docs.redirects import DocsRedirectMiddleware +from reflex_docs.templates.docpage.docpage import breadcrumb_data from reflex_docs.whitelist import _check_whitelisted_path # This number discovered by trial and error on Windows 11 w/ Node 18, any @@ -48,7 +52,7 @@ def _llms_txt_directive() -> rx.Component: radius="large", accent_color="violet", ), - head_components=get_pixel_website_trackers() + favicons_links(), + head_components=favicons_links(), ) app.register_lifespan_task(monitor_checkly_status) @@ -82,7 +86,7 @@ def _canonical_url(path: str) -> str: # "/docsoverview/" instead of "/docs/overview/". if not path.startswith("/"): path = "/" + path - url = REFLEX_DOMAIN_URL.rstrip("/") + _FRONTEND_PATH + path + url = public_url(path) return url if url.endswith("/") else url + "/" @@ -135,6 +139,16 @@ def _canonical_url(path: str) -> str: # can't be emitted, so keep any page-provided meta as-is. canonical = None meta = list(route.meta) if route.meta is not None else [] + if canonical is not None: + meta.append( + rx.el.script( + json.dumps( + breadcrumb_data(route.path, head_title.split(" · ")[0]), + ensure_ascii=False, + ).replace("<", "\\u003c"), + type="application/ld+json", + ) + ) meta.append({"name": "theme-color", "content": route.background_color}) # Reflex's compiler always renders exactly one og:image from add_page's @@ -188,21 +202,50 @@ def _canonical_url(path: str) -> str: ]) -def _redirect_page(): +def _redirect_page(target: str): + """Render a usable destination link for static hosting. + + Args: + target: The app-relative destination, prefixed by the router. + + Returns: + The static redirect page content. + """ return rx.fragment( - rx.el.h1("Redirecting", class_name="sr-only"), + rx.el.h1("This page has moved"), + rx.el.a("Continue to the documentation", href=target), ) for source, target in redirects: if _check_whitelisted_path(target): app.add_page( - _redirect_page, + partial(_redirect_page, target), route=source, title="Redirecting - Reflex Web Framework", description="You are being redirected to the requested page.", on_load=rx.redirect(target), context={"sitemap": None}, + meta=[ + rx.el.link(rel="canonical", href=_canonical_url(target)), + {"name": "robots", "content": "noindex, follow"}, + rx.el.meta( + http_equiv="refresh", content=f"0;url={_FRONTEND_PATH}{target}" + ), + ], ) app.add_page(page404.component, route=page404.path) + + +# HTTP 301 applies when page requests reach this backend. Separate frontend +# hosts use the redirect pages above and need edge rules for HTTP 301 semantics. +app.api_transformer = partial( + DocsRedirectMiddleware, + redirects=[ + (source, target) + for source, target in redirects + if _check_whitelisted_path(target) + ], + frontend_path=_FRONTEND_PATH, +) diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index 1900f38728b..4de61d1528e 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -26,6 +26,7 @@ from reflex_site_shared.route import Route, get_path from reflex_site_shared.templates.docs import docs_layout_shell from reflex_site_shared.utils.docpage import right_sidebar_item_highlight +from reflex_site_shared.utils.url import public_url _REGISTERED_DOC_ROUTES: set[str] = set() @@ -182,6 +183,54 @@ def docpage_footer(path: rx.Var[str], edit_href: rx.Var[str]) -> rx.Component: LLMS_FULL_TXT_PATH = "/llms-full.txt" +def breadcrumb_data(path: str, title: str) -> dict: + """Build structured breadcrumbs using the visible navigation's route resolver. + + Args: + path: The app-relative documentation path. + title: The current page's name. + + Returns: + A schema.org BreadcrumbList with canonical public URLs. + """ + base = public_url() + canonical = base + _normalize_doc_route(path) + items = [ + { + "@type": "ListItem", + "position": 1, + "name": "Documentation", + "item": base + "/", + } + ] + seen = {base + "/", canonical} + segments = path.strip("/").split("/") + for index, segment in enumerate(segments[:-1], 1): + href = _resolve_breadcrumb_href("/" + "/".join(segments[:index])) + if href is None or base + href in seen: + continue + label = to_title_case(to_snake_case(segment), sep=" ") + items.append({ + "@type": "ListItem", + "position": len(items) + 1, + "name": _BREADCRUMB_LABEL_OVERRIDES.get(label, label), + "item": base + href, + }) + seen.add(base + href) + if canonical != base + "/": + items.append({ + "@type": "ListItem", + "position": len(items) + 1, + "name": title, + "item": canonical, + }) + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": items, + } + + def breadcrumb(path: str, nav_sidebar: rx.Component, doc_content: str | None = None): from reflex_docs.components.docpage.navbar.buttons.sidebar import ( docs_sidebar_drawer, @@ -243,12 +292,15 @@ def breadcrumb(path: str, nav_sidebar: rx.Component, doc_content: str | None = N return rx.box( docs_sidebar_drawer( nav_sidebar, - trigger=rx.box( - class_name="absolute inset-0 bg-transparent z-[1] lg:hidden flex", + trigger=rx.el.button( + type="button", + aria_label="Open documentation navigation", + class_name="absolute inset-0 bg-transparent z-[1] lg:hidden flex focus-visible:outline-2 focus-visible:outline-primary-9", ), ), - rx.box( + rx.el.nav( *breadcrumbs, + aria_label="Breadcrumb", class_name="flex flex-row items-center gap-[5px] lg:gap-4 overflow-hidden", ), rx.box( @@ -467,52 +519,11 @@ def wrapper(*args, **kwargs) -> rx.Component: on_mount=rx.call_script(right_sidebar_item_highlight()), ) - # Section is the first path segment (these routes are mounted under - # /docs at runtime, so the path itself has no "docs" prefix). - segments = [c for c in path.split("/") if c] - section = segments[0] if len(segments) > 1 else None - category = ( - " ".join(word.capitalize() for word in section.replace("-", " ").split()) - if section - else None - ) - # Drop the section if it just repeats the page title (avoids titles like - # "Introduction · Introduction · Reflex Docs"). - if category and category.lower() == title.lower(): - category = None - - # Build a descriptive, length-appropriate
{
+ // Hook doubles expose the actual component's render/effect boundary without a DOM.
+ const hooksUrl = moduleUrl(`
+ export { createElement } from ${JSON.stringify(resolve("react"))};
+ let state = null;
+ export let effect;
+ export const useState = () => [state, value => { state = value; }];
+ export const useRef = () => ({current: {}});
+ export const useEffect = callback => { effect = callback; };
+ export const reset = () => { state = null; };
+ `);
+ const highlighterUrl = moduleUrl(`
+ export let request;
+ export const codeToHtml = () => new Promise((resolve, reject) => { request = {resolve, reject}; });
+ `);
+ const hooks = await import(hooksUrl);
+ const highlighter = await import(highlighterUrl);
+ const filename = path.join(root, "packages/reflex-base/src/reflex_base/.templates/web/components/shiki/code.js");
+ const source = (await readFile(filename, "utf8"))
+ .replaceAll('from "react"', `from "${hooksUrl}"`)
+ .replaceAll('import("shiki")', `import("${highlighterUrl}")`);
+ const { Code } = await import(moduleUrl(source));
+ const { renderToStaticMarkup } = await import(resolve("react-dom/server"));
+ const previousWindow = globalThis.window;
+ const scheduled = [];
+ globalThis.window = {setTimeout: callback => scheduled.push(callback), clearTimeout() {}};
+ const warnings = t.mock.method(console, "warn", () => {});
+ const start = async () => {
+ const finished = scheduled.shift()();
+ await new Promise(setImmediate);
+ return {finished, request: highlighter.request};
+ };
+ const initial = {code: "print(1)", language: "python", theme: "github-light"};
+ try {
+ for (const change of [
+ {code: "print(2)"}, {theme: "github-dark"}, {language: "javascript"},
+ {themes: {light: "github-light", dark: "github-dark"}},
+ {transformers: [{pre() {}}]}, {decorations: [{start: 0, end: 1}]},
+ ]) {
+ const label = Object.keys(change)[0];
+ hooks.reset();
+ Code(initial);
+ const cleanup = hooks.effect();
+ const first = await start();
+ first.request.resolve("original highlighting
");
+ await first.finished;
+ assert.equal(Code(initial).props.dangerouslySetInnerHTML.__html, "original highlighting
");
+ cleanup();
+
+ const changed = {...initial, ...change};
+ const fallback = Code(changed);
+ assert.equal(fallback.props.dangerouslySetInnerHTML, undefined, `${label} invalidates cached HTML immediately`);
+ assert.match(renderToStaticMarkup(fallback), /print\([12]\)/);
+ const cleanupChanged = hooks.effect();
+ const replacement = await start();
+ replacement.request.reject(new Error("Highlight failed"));
+ await replacement.finished;
+ assert.equal(Code(changed).props.dangerouslySetInnerHTML, undefined, `${label} failure keeps readable fallback`);
+ cleanupChanged();
+ }
+ assert.equal(warnings.mock.callCount(), 6);
+
+ hooks.reset();
+ Code(initial);
+ const cleanupOld = hooks.effect();
+ const old = await start();
+ cleanupOld();
+ const current = {...initial, theme: "github-dark"};
+ Code(current);
+ const cleanupCurrent = hooks.effect();
+ const latest = await start();
+ latest.request.resolve("current highlighting
");
+ await latest.finished;
+ old.request.resolve("obsolete highlighting
");
+ await old.finished;
+ assert.equal(Code(current).props.dangerouslySetInnerHTML.__html, "current highlighting
");
+ cleanupCurrent();
+ } finally {
+ if (previousWindow === undefined) delete globalThis.window;
+ else globalThis.window = previousWindow;
+ }
+});
+
+test("bundles discard unused memos and preserve custom wrapper side effects", async () => {
+ const { rolldown } = await import(resolve("rolldown"));
+ const source = execFileSync("uv", ["run", "--no-sync", "python", "-c", `
+import reflex as rx
+from reflex_base.compiler.templates import _render_memo_component
+print('import {memo} from "react";')
+print('function track(fn) { globalThis.customWrapperRan = true; return fn; }')
+for name, wrapper in [('Used', 'memo'), ('Unused', 'memo'), ('Tracked', 'track')]:
+ print(_render_memo_component(dict(name=name, display_name=name, signature='', render=rx.el.div(name.upper()+'_PAYLOAD').render(), hooks={}, wrapper=wrapper, pure_wrapper=wrapper == 'memo')))
+`], {cwd: root, encoding: "utf8"});
+ const bundle = await rolldown({
+ input: "entry", external: ["react"],
+ plugins: [{ name: "fixture", resolveId: (id) => id, load: (id) => id === "entry" ? 'import {Used} from "fixture"; globalThis.result=Used;' : source }],
+ });
+ try {
+ const { output } = await bundle.generate({format: "es"});
+ assert(!output[0].code.includes("UNUSED_PAYLOAD"));
+ assert(output[0].code.includes("USED_PAYLOAD"));
+ assert(output[0].code.includes("displayName"));
+ assert(output[0].code.includes("customWrapperRan"));
+ } finally {
+ await bundle.close();
+ }
+});
+
+test("optional registries load on demand, share requests, and retry failures", async () => {
+ const rootCode = execFileSync("uv", ["run", "--no-sync", "python", "-c", `
+from unittest.mock import patch
+import reflex as rx
+from reflex.compiler.compiler import compile_app_root
+from reflex_base.components.dynamic import bundle_library
+from reflex_base.registry import RegistrationContext
+with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_value=rx.Config(app_name='quality', frontend_lazy_bundled_libraries=True)):
+ bundle_library('quality-lazy-fixture')
+ print(compile_app_root(rx.el.div('Quality'))[1])
+`], {cwd: root, encoding: "utf8"});
+ const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_state", "loadFixture",
+ rootCode.replace(/^import .*$/gm, "")
+ .replace(/^export default function/gm, "function")
+ .replace(/^export /gm, "")
+ .replace('import("quality-lazy-fixture")', 'loadFixture()'));
+ const stateSource = await readFile(path.join(root, "packages/reflex-base/src/reflex_base/.templates/web/utils/state.js"), "utf8");
+ // Parse the complete module so nested statements cannot truncate the export.
+ const { parseAst } = await import(resolve("rolldown/parseAst"));
+ const stateModule = parseAst(stateSource);
+ const evaluateExport = stateModule.body.find(node => node.type === "ExportNamedDeclaration" &&
+ node.declaration?.declarations?.some(declaration => declaration.id.name === "evalReactComponent"));
+ assert(evaluateExport, "state.js exports evalReactComponent");
+ const { evalReactComponent: evaluate } = await import(moduleUrl(stateSource.slice(evaluateExport.start, evaluateExport.end)));
+ const previousWindow = globalThis.window;
+ const react = {quality: "same React instance"};
+ try {
+ let calls = 0;
+ globalThis.window = {};
+ setup(window, react, {}, {}, {}, async () => {calls++; return {value: 42};});
+ assert.equal(calls, 0);
+ assert.equal(window.__reflex.react, react);
+ assert.equal(window.__reflex["quality-lazy-fixture"], undefined);
+ const source = 'const {value} = window.__reflex["quality-lazy-fixture"]; export default function Quality(){ return value; }';
+ const components = await Promise.all([evaluate(source), evaluate(source)]);
+ assert.equal(calls, 1);
+ assert.deepEqual(components.map(component => component()), [42, 42]);
+ assert.equal(window.React, react);
+ await evaluate(source);
+ assert.equal(calls, 1);
+
+ let attempts = 0;
+ globalThis.window = {};
+ setup(window, react, {}, {}, {}, async () => {
+ if (++attempts === 1) throw new Error("Temporary download failure");
+ return {value: 43};
+ });
+ await assert.rejects(evaluate(source), /Temporary download failure/);
+ const recovered = await evaluate(source + "\n// retry fixture");
+ assert.equal(attempts, 2);
+ assert.equal(recovered(), 43);
+
+ globalThis.window = {__reflex: {react}};
+ const legacy = await evaluate('export default function Legacy(){ return window.__reflex.react.quality; }');
+ assert.equal(legacy(), "same React instance");
+ } finally {
+ if (previousWindow === undefined) delete globalThis.window;
+ else globalThis.window = previousWindow;
+ }
+});
diff --git a/docs/app/tests/test_breadcrumbs.py b/docs/app/tests/test_breadcrumbs.py
index fa5e66eb0d8..7776b30e16f 100644
--- a/docs/app/tests/test_breadcrumbs.py
+++ b/docs/app/tests/test_breadcrumbs.py
@@ -1,7 +1,9 @@
"""Tests for docs breadcrumbs."""
import importlib
+from types import SimpleNamespace
+import pytest
import reflex as rx
@@ -60,3 +62,65 @@ def test_resolve_breadcrumb_href_returns_none_for_missing_route():
docpage_module._resolve_breadcrumb_href("/hosting", {"/hosting/deploy/"})
is None
)
+
+
+@pytest.mark.parametrize(
+ "deploy_url,frontend_path,base",
+ [
+ ("https://reflex.dev", "/docs", "https://reflex.dev/docs"),
+ ("http://localhost:3000", "/docs", "http://localhost:3000/docs"),
+ (
+ "https://staging.example.com/",
+ "/preview/docs/",
+ "https://staging.example.com/preview/docs",
+ ),
+ ("https://docs.example.com/", "", "https://docs.example.com"),
+ ],
+)
+def test_structured_breadcrumbs_use_real_canonical_routes(
+ monkeypatch, deploy_url, frontend_path, base
+):
+ """Structured navigation names existing pages and includes the docs root."""
+ docpage_module = importlib.import_module("reflex_docs.templates.docpage.docpage")
+ monkeypatch.setattr(
+ "reflex_site_shared.utils.url.get_config",
+ lambda: SimpleNamespace(deploy_url=deploy_url, frontend_path=frontend_path),
+ )
+ monkeypatch.setattr(
+ docpage_module,
+ "_REGISTERED_DOC_ROUTES",
+ {
+ "/enterprise/overview/",
+ "/enterprise/auth/overview/",
+ "/enterprise/auth/testing/",
+ },
+ )
+ data = docpage_module.breadcrumb_data("/enterprise/auth/testing/", "Testing")
+ assert data["@type"] == "BreadcrumbList"
+ items = data["itemListElement"]
+ assert [item["position"] for item in items] == list(range(1, len(items) + 1))
+ assert [item["item"] for item in items] == [
+ base + "/",
+ base + "/enterprise/overview/",
+ base + "/enterprise/auth/overview/",
+ base + "/enterprise/auth/testing/",
+ ]
+
+
+@pytest.mark.parametrize("path", ["/", ""])
+def test_root_breadcrumb_has_one_location(path, monkeypatch):
+ """The docs root must not repeat itself as the current location."""
+ docpage_module = importlib.import_module("reflex_docs.templates.docpage.docpage")
+ monkeypatch.setattr(
+ "reflex_site_shared.utils.url.get_config",
+ lambda: SimpleNamespace(deploy_url="https://reflex.dev", frontend_path="/docs"),
+ )
+ items = docpage_module.breadcrumb_data(path, "Documentation")["itemListElement"]
+ assert items == [
+ {
+ "@type": "ListItem",
+ "position": 1,
+ "name": "Documentation",
+ "item": "https://reflex.dev/docs/",
+ }
+ ]
diff --git a/docs/app/tests/test_config.py b/docs/app/tests/test_config.py
new file mode 100644
index 00000000000..304fea3388b
--- /dev/null
+++ b/docs/app/tests/test_config.py
@@ -0,0 +1,25 @@
+"""Deployment settings for local and hosted documentation builds."""
+
+import runpy
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.parametrize(
+ "deploy_url,expected",
+ [
+ (None, "http://localhost:3000"),
+ ("https://staging.example.com", "https://staging.example.com"),
+ ],
+)
+def test_docs_deployment_origin_comes_from_environment(
+ monkeypatch, deploy_url, expected
+):
+ """Local runs keep the framework default; deployment jobs supply their origin."""
+ monkeypatch.delenv("REFLEX_DEPLOY_URL", raising=False)
+ monkeypatch.delenv("REFLEX_FRONTEND_PORT", raising=False)
+ if deploy_url is not None:
+ monkeypatch.setenv("REFLEX_DEPLOY_URL", deploy_url)
+ config = runpy.run_path(str(Path(__file__).parents[1] / "rxconfig.py"))["config"]
+ assert config.deploy_url == expected
diff --git a/docs/app/tests/test_doc_description.py b/docs/app/tests/test_doc_description.py
index 3f23dbad3e8..0a6c9955685 100644
--- a/docs/app/tests/test_doc_description.py
+++ b/docs/app/tests/test_doc_description.py
@@ -96,3 +96,9 @@ def test_frontmatter_description_is_truncated_to_max_len():
assert result is not None
assert len(result) <= 155
assert result.endswith("…")
+
+
+def test_concise_prose_is_kept_instead_of_generic_framework_description():
+ """Useful short summaries should not be discarded to meet an arbitrary floor."""
+ summary = "Configure authentication providers and sign-in flows for Reflex Enterprise apps."
+ assert extract_doc_description(summary) == summary
diff --git a/docs/app/tests/test_doc_links.py b/docs/app/tests/test_doc_links.py
index b45df97293b..ea6c8ac1379 100644
--- a/docs/app/tests/test_doc_links.py
+++ b/docs/app/tests/test_doc_links.py
@@ -19,6 +19,7 @@
from urllib.parse import urlparse
import pytest
+from reflex_base.environment import environment
from reflex_docgen.markdown import (
Block,
BoldSpan,
@@ -36,8 +37,17 @@
parse_document,
)
-SITEMAP_NS = {"sm": "https://www.sitemaps.org/schemas/sitemap/0.9"}
-SKIP_DIRS = {".web", "node_modules", "__pycache__", ".git", ".venv", "dist", "build"}
+SITEMAP_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
+SKIP_DIRS = {
+ ".web",
+ "node_modules",
+ "__pycache__",
+ ".git",
+ ".venv",
+ "dist",
+ "build",
+ environment.REFLEX_WEB_WORKDIR.get().name,
+}
def _normalize(path: str) -> str:
@@ -185,7 +195,7 @@ def check(md_root: Path, sitemap_path: Path) -> list[str]:
_DOCS_APP = Path(__file__).resolve().parent.parent # docs/app/
_MD_ROOT = _DOCS_APP.parent # docs/
-_SITEMAP = _DOCS_APP / ".web" / "public" / "sitemap.xml"
+_SITEMAP = _DOCS_APP / environment.REFLEX_WEB_WORKDIR.get() / "public" / "sitemap.xml"
@pytest.mark.xfail(
@@ -205,14 +215,14 @@ def test_docs_links_against_exported_sitemap():
SITEMAP_XML = """
-
+
http://localhost:3000/getting-started/basics/
http://localhost:3000/library/disclosure/
"""
SITEMAP_XML_WITH_DOCS_PREFIX = """
-
+
http://localhost:3000/docs/getting-started/basics/
http://localhost:3000/docs/library/disclosure/
@@ -236,6 +246,18 @@ def test_normalize_strips_fragment_query_and_trailing_slash():
assert _normalize("/") == "/"
+def test_load_sitemap_uses_the_standard_xml_namespace(tmp_path):
+ """A standards-compliant sitemap must populate the link validator's paths."""
+ sitemap = tmp_path / "sitemap.xml"
+ sitemap.write_text(
+ ''
+ "https://reflex.dev/docs/ "
+ "https://reflex.dev/docs/api-reference/app/ "
+ " "
+ )
+ assert _load_sitemap_paths(sitemap) == {"/", "/api-reference/app"}
+
+
def test_check_passes_for_valid_link(docs_tree):
md_root, sitemap = docs_tree
(md_root / "page.md").write_text("[ok](/docs/getting-started/basics)\n")
diff --git a/docs/app/tests/test_redirects.py b/docs/app/tests/test_redirects.py
new file mode 100644
index 00000000000..0b316391e18
--- /dev/null
+++ b/docs/app/tests/test_redirects.py
@@ -0,0 +1,56 @@
+"""HTTP semantics for the docs' renamed routes."""
+
+import pytest
+from starlette.applications import Starlette
+from starlette.responses import PlainTextResponse
+from starlette.routing import Route
+from starlette.testclient import TestClient
+
+from reflex_docs.redirects import DocsRedirectMiddleware
+
+
+@pytest.mark.parametrize("method", ["GET", "HEAD"])
+@pytest.mark.parametrize("suffix", ["", "/"])
+def test_legacy_docs_redirect_before_rendering(method, suffix):
+ """Old URLs redirect in one hop without JavaScript and retain query strings."""
+ app = DocsRedirectMiddleware(
+ Starlette(
+ routes=[
+ Route(
+ "/{path:path}",
+ lambda request: PlainTextResponse("next"),
+ methods=["GET", "HEAD", "POST"],
+ )
+ ]
+ ),
+ redirects=[("/old/", "/new/")],
+ frontend_path="/docs",
+ )
+ with TestClient(app, follow_redirects=False) as client:
+ response = client.request(method, f"/docs/old{suffix}?q=one%20two")
+ assert response.status_code == 301
+ assert response.headers["location"] == "/docs/new/?q=one%20two"
+
+
+@pytest.mark.parametrize(
+ "method,path", [("GET", "/old/"), ("GET", "/docs/new/"), ("POST", "/docs/old/")]
+)
+def test_non_redirect_requests_pass_through(method, path):
+ """Only GET/HEAD requests to known legacy docs paths are redirected."""
+ app = DocsRedirectMiddleware(
+ Starlette(
+ routes=[
+ Route(
+ "/{path:path}",
+ lambda request: PlainTextResponse("next"),
+ methods=["GET", "HEAD", "POST"],
+ )
+ ]
+ ),
+ redirects=[("/old/", "/new/")],
+ frontend_path="/docs",
+ )
+ with TestClient(app, follow_redirects=False) as client:
+ response = client.request(method, path)
+ assert response.status_code == 200
+ assert response.text == "next"
diff --git a/docs/app/tests/test_routes.py b/docs/app/tests/test_routes.py
index 162309bae75..2fbc0eac99b 100644
--- a/docs/app/tests/test_routes.py
+++ b/docs/app/tests/test_routes.py
@@ -7,6 +7,18 @@
import pytest
import reflex as rx
+from reflex_docs.pages.docs.metadata import docs_metadata
+
+
+@pytest.mark.parametrize(
+ ("title", "expected"),
+ [("Cli", "CLI"), ("Api Reference", "API Reference"), ("rx.html", "rx.html")],
+)
+def test_metadata_preserves_acronyms_and_code_identifiers(title, expected):
+ """Normalize standalone acronyms without rewriting component names."""
+ seo_title, _ = docs_metadata("/api-reference/cli/", title, None)
+ assert seo_title.startswith(expected + " · ")
+
@pytest.fixture
def routes_fixture():
@@ -325,3 +337,18 @@ def test_docs_do_not_link_to_retired_demo_apps():
offenders[virtual] = found
assert offenders == {}, f"Docs link to retired demo apps: {offenders}"
+
+
+def test_docs_titles_and_descriptions_are_unique(routes_fixture):
+ """Search snippets distinguish pages in different product sections."""
+ for attr in ("title", "description"):
+ values = [
+ (route.seo_title or route.title) if attr == "title" else route.description
+ for route in routes_fixture
+ ]
+ duplicates = {
+ value: count
+ for value, count in Counter(values).items()
+ if value and count > 1
+ }
+ assert duplicates == {}, (attr, duplicates)
diff --git a/docs/app/tests/test_sidebar.py b/docs/app/tests/test_sidebar.py
index ce7d0cef4bd..3e39df735f8 100644
--- a/docs/app/tests/test_sidebar.py
+++ b/docs/app/tests/test_sidebar.py
@@ -1,5 +1,18 @@
"""Tests for the docs sidebar structure and prev/next chain."""
+import pytest
+
+
+@pytest.mark.parametrize("label", ["APIs", "URLs"])
+def test_ai_integration_group_and_page_use_matching_acronyms(label):
+ """Keep plural acronyms consistent between sidebar groups and their pages."""
+ from reflex_docs.templates.docpage.sidebar.sidebar_items.ai import (
+ get_ai_builder_integrations,
+ )
+
+ group = next(item for item in get_ai_builder_integrations() if item.names == label)
+ assert [child.names for child in group.children] == [label]
+
def test_backend_authentication_links_to_enterprise_auth():
"""The backend Authentication entry is a cross-reference to the enterprise auth docs."""
diff --git a/docs/app/tests/test_site_quality.py b/docs/app/tests/test_site_quality.py
new file mode 100644
index 00000000000..dd3f232c29b
--- /dev/null
+++ b/docs/app/tests/test_site_quality.py
@@ -0,0 +1,55 @@
+"""Regression checks for shared documentation presentation."""
+
+import reflex as rx
+
+from reflex_docs.pages.docs.source import stacked_description_rows
+from reflex_docs.pages.docs_landing.views.ai_builder import card
+from reflex_docs.pages.docs_landing.views.link_item import link_item
+from reflex_docs.templates.docpage.docpage import breadcrumb
+
+
+def test_mobile_breadcrumb_drawer_has_a_named_button_trigger():
+ """The mobile navigation overlay must be keyboard and screen-reader usable."""
+ rendered = str(breadcrumb("/getting-started/introduction/", rx.el.nav()))
+ assert '"aria-label":"Open documentation navigation"' in rendered
+
+
+def test_landing_card_link_has_an_accessible_name():
+ """Overlay links remain understandable without the surrounding visual card."""
+ rendered = str(link_item("BookOpen01Icon", "Learn Reflex", "Start here", "/intro/"))
+ assert "aria-label" in rendered and "Learn Reflex" in rendered
+
+
+def test_ai_card_focus_outline_is_inside_the_clipped_card():
+ """The overlay's focus indicator must fit within the rounded clipping box."""
+ rendered = str(card("AI Builder", "Build an app", "Preview", "/ai/"))
+ assert "focus-visible:-outline-offset-4" in rendered
+ assert "focus-visible:outline-offset-4" not in rendered
+
+
+def test_responsive_description_is_rendered_once():
+ """Mobile and desktop layouts share one description and its element IDs."""
+ calls = []
+
+ def description():
+ """Return content with a document-wide unique anchor."""
+ calls.append(True)
+ return rx.el.p("Field description", id="unique-description")
+
+ rows = stacked_description_rows([(rx.text("Field"), "")], description, "2xl")
+ assert len(calls) == 1
+ assert sum(str(row).count('id:"unique-description"') for row in rows) == 1
+
+
+def test_deferred_demo_preserves_code_and_defers_only_the_preview():
+ """Heavy previews can wait for the viewport while their code stays readable."""
+ from reflex_docs.docgen_pipeline import render_markdown
+
+ rendered = str(
+ render_markdown(
+ '```python demo exec defer\nimport reflex as rx\ndef preview():\n return rx.text("Preview")\n```'
+ )
+ )
+ assert "DeferredDemo" in rendered
+ assert "Preview" in rendered
+ assert "rx.text" in rendered
diff --git a/docs/enterprise/ag_grid/index.md b/docs/enterprise/ag_grid/index.md
index 5bf68c59d1d..a78594b5372 100644
--- a/docs/enterprise/ag_grid/index.md
+++ b/docs/enterprise/ag_grid/index.md
@@ -189,7 +189,7 @@ column_defs = [
]
```
-Enterprise filters may require loading their modules explicitly via the `enterprise_modules` prop — for example `SetFilterModule` for `agSetColumnFilter`, `MultiFilterModule` for `agMultiColumnFilter`, and `FiltersToolPanelModule` for the filter tool panel (shown with `side_bar=True`). See [Functionality you need is not available/working in Reflex](#functionality-you-need-is-not-availableworking-in-reflex) below.
+Enterprise filters may require loading their modules explicitly via the `enterprise_modules` prop — for example `SetFilterModule` for `agSetColumnFilter`, `MultiFilterModule` for `agMultiColumnFilter`, and `FiltersToolPanelModule` for the filter tool panel (shown with `side_bar=True`). See [Functionality you need is not available/working in Reflex](#functionality-you-need-is-not-available/working-in-reflex) below.
## Row Sorting
diff --git a/docs/getting_started/introduction.md b/docs/getting_started/introduction.md
index d6a9f5850bb..9afea4242bd 100644
--- a/docs/getting_started/introduction.md
+++ b/docs/getting_started/introduction.md
@@ -61,6 +61,10 @@ def counter_code_section(code: str, tab: str) -> rx.Component:
rx.code_block(
code,
class_name="code-block counter-code-block",
+ theme=rx.color_mode_cond(
+ light=rx.code_block.themes.vs,
+ dark=rx.code_block.themes.vsc_dark_plus,
+ ),
),
background=rx.cond(active, "var(--c-violet-3)", "transparent"),
border_left=rx.cond(
@@ -121,12 +125,14 @@ rx.hstack(
rx.button(
"Decrement",
color_scheme="ruby",
+ high_contrast=True,
on_click=CounterExampleState.decrement,
),
rx.heading(CounterExampleState.count, as_="h2", font_size="2em"),
rx.button(
"Increment",
color_scheme="grass",
+ high_contrast=True,
on_click=CounterExampleState.increment,
),
spacing="4",
@@ -163,12 +169,14 @@ rx.box(
rx.button(
"Decrement",
color_scheme="ruby",
+ high_contrast=True,
on_click=State.decrement,
),
rx.heading(State.count, font_size="2em"),
rx.button(
"Increment",
color_scheme="grass",
+ high_contrast=True,
on_click=State.increment,
),
spacing="4",
@@ -232,12 +240,14 @@ def index():
rx.button(
"Decrement",
color_scheme="ruby",
+ high_contrast=True,
on_click=State.decrement,
),
rx.heading(State.count, as_="h2", font_size="2em"),
rx.button(
"Increment",
color_scheme="grass",
+ high_contrast=True,
on_click=State.increment,
),
spacing="4",
diff --git a/docs/library/graphing/other-charts/plotly.md b/docs/library/graphing/other-charts/plotly.md
index bb9fa746bd4..f84b438c97e 100644
--- a/docs/library/graphing/other-charts/plotly.md
+++ b/docs/library/graphing/other-charts/plotly.md
@@ -24,7 +24,7 @@ import plotly.graph_objects as go
Let's create a line graph of life expectancy in Canada.
-```python demo exec
+```python demo exec defer
import plotly.express as px
df = px.data.gapminder().query("country=='Canada'")
@@ -33,7 +33,7 @@ fig = px.line(df, x="year", y="lifeExp", title="Life expectancy in Canada")
def line_chart():
return rx.center(
- rx.plotly(data=fig),
+ rx.plotly(width="100%", data=fig),
)
```
@@ -45,7 +45,7 @@ def line_chart():
Create a Plotly Express bar chart with `px.bar`:
-```python demo exec
+```python demo exec defer
oceania = px.data.gapminder().query("continent == 'Oceania'")
bar_fig = px.bar(
oceania, x="year", y="pop", color="country", title="Population of Oceania"
@@ -53,14 +53,14 @@ bar_fig = px.bar(
def plotly_bar_chart():
- return rx.center(rx.plotly(data=bar_fig))
+ return rx.center(rx.plotly(width="100%", data=bar_fig))
```
### Scatter Plot
Create a Plotly scatter plot with `px.scatter`:
-```python demo exec
+```python demo exec defer
iris = px.data.iris()
scatter_fig = px.scatter(
iris,
@@ -72,27 +72,27 @@ scatter_fig = px.scatter(
def plotly_scatter_plot():
- return rx.center(rx.plotly(data=scatter_fig))
+ return rx.center(rx.plotly(width="100%", data=scatter_fig))
```
### Pie Chart
Create a Plotly pie chart with `px.pie`:
-```python demo exec
+```python demo exec defer
tips = px.data.tips()
pie_fig = px.pie(tips, values="tip", names="day", title="Tips by day")
def plotly_pie_chart():
- return rx.center(rx.plotly(data=pie_fig))
+ return rx.center(rx.plotly(width="100%", data=pie_fig))
```
### Heatmap
Create a Plotly heatmap with `px.density_heatmap`:
-```python demo exec
+```python demo exec defer
tips_data = px.data.tips()
heatmap_fig = px.density_heatmap(
tips_data, x="total_bill", y="tip", title="Bill vs tip density heatmap"
@@ -100,14 +100,14 @@ heatmap_fig = px.density_heatmap(
def plotly_heatmap():
- return rx.center(rx.plotly(data=heatmap_fig))
+ return rx.center(rx.plotly(width="100%", data=heatmap_fig))
```
### Histogram
Create a Plotly histogram with `px.histogram`:
-```python demo exec
+```python demo exec defer
hist_data = px.data.tips()
histogram_fig = px.histogram(
hist_data, x="total_bill", nbins=20, title="Distribution of total bills"
@@ -115,27 +115,27 @@ histogram_fig = px.histogram(
def plotly_histogram():
- return rx.center(rx.plotly(data=histogram_fig))
+ return rx.center(rx.plotly(width="100%", data=histogram_fig))
```
### Box Plot
Create a Plotly box plot with `px.box`:
-```python demo exec
+```python demo exec defer
box_data = px.data.tips()
box_fig = px.box(box_data, x="day", y="total_bill", title="Total bill by day")
def plotly_box_plot():
- return rx.center(rx.plotly(data=box_fig))
+ return rx.center(rx.plotly(width="100%", data=box_fig))
```
### Bubble Chart
A bubble chart is a scatter plot in which a third dimension of the data is shown through the size of the markers. Create one with `px.scatter` by passing a column to the `size` argument:
-```python demo exec
+```python demo exec defer
gapminder = px.data.gapminder()
bubble_fig = px.scatter(
gapminder.query("year==2007"),
@@ -151,14 +151,14 @@ bubble_fig = px.scatter(
def plotly_bubble_chart():
- return rx.center(rx.plotly(data=bubble_fig))
+ return rx.center(rx.plotly(width="100%", data=bubble_fig))
```
### Gantt Chart
A Gantt chart is a type of bar chart that illustrates a project schedule: tasks are listed on the vertical axis, time intervals on the horizontal axis, and the width of each bar shows the duration of the activity. Create one with `px.timeline`:
-```python demo exec
+```python demo exec defer
tasks = pd.DataFrame([
dict(Task="Job A", Start="2009-01-01", Finish="2009-02-28"),
dict(Task="Job B", Start="2009-03-05", Finish="2009-04-15"),
@@ -170,14 +170,14 @@ gantt_fig.update_yaxes(autorange="reversed")
def plotly_gantt_chart():
- return rx.center(rx.plotly(data=gantt_fig))
+ return rx.center(rx.plotly(width="100%", data=gantt_fig))
```
### Sunburst Chart
Sunburst charts visualize hierarchical data spanning outwards radially from root to leaves: the root sits at the center and children are added to the outer rings. Create one with `px.sunburst`, defining the hierarchy with `names` and `parents`:
-```python demo exec
+```python demo exec defer
family = dict(
character=["Eve", "Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
parent=["", "Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve"],
@@ -187,14 +187,14 @@ sunburst_fig = px.sunburst(family, names="character", parents="parent", values="
def plotly_sunburst_chart():
- return rx.center(rx.plotly(data=sunburst_fig))
+ return rx.center(rx.plotly(width="100%", data=sunburst_fig))
```
### Funnel Chart
Funnel charts represent data as it moves through the stages of a business process, making them a common Business Intelligence tool for spotting where a process loses volume. Create one with `px.funnel`:
-```python demo exec
+```python demo exec defer
funnel_data = dict(
number=[39, 27.4, 20.6, 11, 2],
stage=[
@@ -209,14 +209,14 @@ funnel_fig = px.funnel(funnel_data, x="number", y="stage")
def plotly_funnel_chart():
- return rx.center(rx.plotly(data=funnel_fig))
+ return rx.center(rx.plotly(width="100%", data=funnel_fig))
```
## Locale Configuration
Use `locale` to localize Plotly number/date formatting and modebar labels:
-```python demo exec
+```python demo exec defer
df = px.data.gapminder().query("country=='Canada'")
fig = px.line(df, x="year", y="lifeExp", title="Life expectancy in Canada")
@@ -224,6 +224,7 @@ fig = px.line(df, x="year", y="lifeExp", title="Life expectancy in Canada")
def localized_line_chart():
return rx.center(
rx.plotly(
+ width="100%",
data=fig,
locale="de",
),
@@ -236,7 +237,7 @@ You can still pass `config`; when both are provided, `locale=` is applied as the
Let's create a 3D surface plot of Mount Bruno. This is a slightly more complicated example, but it wraps in Reflex using the same method. In fact, you can wrap any figure using the same approach.
-```python demo exec
+```python demo exec defer
import plotly.graph_objects as go
import pandas as pd
@@ -256,7 +257,7 @@ fig.update_layout(
def mountain_surface():
return rx.center(
- rx.plotly(data=fig),
+ rx.plotly(width="100%", data=fig),
)
```
@@ -268,7 +269,7 @@ def mountain_surface():
The candlestick chart is a financial chart describing the open, high, low, and close values for a given x coordinate (most likely time): boxes show the spread between open and close, and lines show the spread between low and high. Create one with `go.Candlestick`:
-```python demo exec
+```python demo exec defer
candles = pd.DataFrame({
"Date": [
"2024-01-02",
@@ -301,14 +302,14 @@ candlestick_fig.update_layout(
def candlestick_chart():
- return rx.center(rx.plotly(data=candlestick_fig))
+ return rx.center(rx.plotly(width="100%", data=candlestick_fig))
```
### Waterfall Chart
The waterfall chart visualizes how an initial value is affected by a series of positive and negative changes — for example, a profit and loss statement. Create one with `go.Waterfall`, marking each value as `"relative"` or `"total"` via the `measure` argument:
-```python demo exec
+```python demo exec defer
waterfall_fig = go.Figure(
go.Waterfall(
name="20",
@@ -332,14 +333,14 @@ waterfall_fig.update_layout(title="Profit and loss statement 2018", showlegend=T
def waterfall_chart():
- return rx.center(rx.plotly(data=waterfall_fig))
+ return rx.center(rx.plotly(width="100%", data=waterfall_fig))
```
### Bullet Chart
The bullet chart, designed by Stephen Few as a compact replacement for dashboard gauges and meters, combines a quantitative bar, qualitative ranges (steps), and a performance threshold line in one simple layout. Build one with `go.Indicator` using the `"bullet"` gauge shape:
-```python demo exec
+```python demo exec defer
bullet_fig = go.Figure(
go.Indicator(
mode="number+gauge+delta",
@@ -366,7 +367,7 @@ bullet_fig = go.Figure(
def bullet_chart():
- return rx.center(rx.plotly(data=bullet_fig))
+ return rx.center(rx.plotly(width="100%", data=bullet_fig))
```
## Statistical Charts
@@ -375,7 +376,7 @@ def bullet_chart():
Continuous error bands represent error or uncertainty as a shaded region around a main trace, rather than as discrete whisker-like error bars. Build one with `go.Scatter` by drawing the main line, then a second trace that walks the upper bound forward and the lower bound in reverse, filled with `fill="toself"`:
-```python demo exec
+```python demo exec defer
band_x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
band_y = [1, 2, 7, 4, 5, 6, 7, 8, 9, 10]
band_y_upper = [2, 3, 8, 5, 6, 7, 8, 9, 10, 11]
@@ -401,7 +402,7 @@ error_band_fig = go.Figure([
def continuous_error_bands_chart():
- return rx.center(rx.plotly(data=error_band_fig))
+ return rx.center(rx.plotly(width="100%", data=error_band_fig))
```
## Maps
@@ -410,7 +411,7 @@ def continuous_error_bands_chart():
Geo maps are outline-based maps drawn from geographic features rather than map tiles. Figures created with `px.scatter_geo`, `px.line_geo`, or `px.choropleth` — or containing `go.Scattergeo` or `go.Choropleth` traces — store their map configuration in the figure's `layout.geo` object, which you can adjust with `update_geos`:
-```python demo exec
+```python demo exec defer
geo_fig = go.Figure(go.Scattergeo())
geo_fig.update_geos(
visible=False,
@@ -424,14 +425,14 @@ geo_fig.update_layout(height=300, margin={"r": 0, "t": 0, "l": 0, "b": 0})
def geo_map_chart():
- return rx.center(rx.plotly(data=geo_fig))
+ return rx.center(rx.plotly(width="100%", data=geo_fig))
```
### Scatter Map
Scatter maps plot markers on a tile-based map, sized and colored by your data — useful for visualizing geographic point data like vehicle locations or store sites. Create one with `px.scatter_map` (or a `go.Scattermap` trace for lower-level control):
-```python demo exec
+```python demo exec defer
carshare = px.data.carshare()
map_fig = px.scatter_map(
carshare,
@@ -446,7 +447,7 @@ map_fig = px.scatter_map(
def scatter_map_chart():
- return rx.center(rx.plotly(data=map_fig))
+ return rx.center(rx.plotly(width="100%", data=map_fig))
```
## Tables and Diagrams
@@ -455,7 +456,7 @@ def scatter_map_chart():
Plotly can also render data as an interactive table. Create one with `go.Table`, passing column headers to `header` and column data to `cells`:
-```python demo exec
+```python demo exec defer
table_fig = go.Figure(
data=[
go.Table(
@@ -467,14 +468,14 @@ table_fig = go.Figure(
def plotly_table():
- return rx.center(rx.plotly(data=table_fig))
+ return rx.center(rx.plotly(width="100%", data=table_fig))
```
### Sankey Diagram
A Sankey diagram is a flow diagram in which the width of the arrows is proportional to the flow quantity. Create one with `go.Sankey`, defining the nodes and the links between them by index:
-```python demo exec
+```python demo exec defer
sankey_fig = go.Figure(
data=[
go.Sankey(
@@ -498,7 +499,7 @@ sankey_fig.update_layout(title_text="Basic Sankey Diagram", font_size=10)
def plotly_sankey_diagram():
- return rx.center(rx.plotly(data=sankey_fig))
+ return rx.center(rx.plotly(width="100%", data=sankey_fig))
```
## 3D Charts
@@ -507,7 +508,7 @@ def plotly_sankey_diagram():
3D scatter plots show the relationship between three variables at once, with an optional fourth encoded as color. Create one with `px.scatter_3d`:
-```python demo exec
+```python demo exec defer
iris_3d = px.data.iris()
scatter_3d_fig = px.scatter_3d(
iris_3d,
@@ -519,14 +520,14 @@ scatter_3d_fig = px.scatter_3d(
def scatter_3d_chart():
- return rx.center(rx.plotly(data=scatter_3d_fig))
+ return rx.center(rx.plotly(width="100%", data=scatter_3d_fig))
```
### 3D Axis
3D figures place their traces in a scene, and each scene axis is configured through the figure's `scene` layout — set `nticks`, `range`, or axis titles per axis. This example renders a `go.Mesh3d` cloud with custom tick counts and ranges on all three axes:
-```python demo exec
+```python demo exec defer
import numpy as np
np.random.seed(1)
@@ -554,14 +555,14 @@ mesh_fig.update_layout(
def axis_3d_chart():
- return rx.center(rx.plotly(data=mesh_fig))
+ return rx.center(rx.plotly(width="100%", data=mesh_fig))
```
## Plot as State Var
If the figure is set as a state var, it can be updated during run time.
-```python demo exec
+```python demo exec defer
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
@@ -600,6 +601,7 @@ def line_chart_with_state():
on_change=PlotlyState.set_selected_country,
),
rx.plotly(
+ width="100%",
data=PlotlyState.figure,
on_mount=PlotlyState.create_figure,
),
@@ -614,7 +616,7 @@ Use `update_layout()` method to update the layout of your chart. Checkout [Plotl
Note that the width and height props are not recommended to ensure the plot remains size responsive to its container. The size of plot will be determined by it's outer container.
```
-```python demo exec
+```python demo exec defer
df = px.data.gapminder().query("country=='Canada'")
fig_1 = px.line(
df,
@@ -634,7 +636,7 @@ fig_1.update_layout(
def add_styles():
return rx.center(
- rx.plotly(data=fig_1),
+ rx.plotly(width="100%", data=fig_1),
width="100%",
height="100%",
)
diff --git a/docs/library/graphing/other-charts/pyplot.md b/docs/library/graphing/other-charts/pyplot.md
index dfc1df0de15..509715ab529 100644
--- a/docs/library/graphing/other-charts/pyplot.md
+++ b/docs/library/graphing/other-charts/pyplot.md
@@ -55,7 +55,14 @@ def create_contour_plot():
def pyplot_simple_example():
return rx.card(
- pyplot(create_contour_plot(), width="100%", height="400px"),
+ pyplot(
+ create_contour_plot(),
+ alt="Filled contour plot of a two-dimensional function",
+ loading="lazy",
+ decoding="async",
+ width="100%",
+ height="400px",
+ ),
bg_color="#ffffff",
width="100%",
)
@@ -92,7 +99,14 @@ def create_line_plot():
def pyplot_line_example():
return rx.card(
- pyplot(create_line_plot(), width="100%", height="400px"),
+ pyplot(
+ create_line_plot(),
+ alt="Sine and cosine curves from zero to ten",
+ loading="lazy",
+ decoding="async",
+ width="100%",
+ height="400px",
+ ),
bg_color="#ffffff",
width="100%",
)
@@ -121,7 +135,14 @@ def create_bar_chart():
def pyplot_bar_example():
return rx.card(
- pyplot(create_bar_chart(), width="100%", height="400px"),
+ pyplot(
+ create_bar_chart(),
+ alt="Fruit inventory: apples 23, oranges 17, bananas 35, pears 29",
+ loading="lazy",
+ decoding="async",
+ width="100%",
+ height="400px",
+ ),
bg_color="#ffffff",
width="100%",
)
@@ -199,8 +220,22 @@ def pyplot_example():
return rx.vstack(
rx.card(
rx.color_mode_cond(
- pyplot(PyplotState.fig_light, width="100%", height="100%"),
- pyplot(PyplotState.fig_dark, width="100%", height="100%"),
+ pyplot(
+ PyplotState.fig_light,
+ alt="Scatter plot of the current randomized points",
+ loading="lazy",
+ decoding="async",
+ width="100%",
+ height="100%",
+ ),
+ pyplot(
+ PyplotState.fig_dark,
+ alt="Scatter plot of the current randomized points",
+ loading="lazy",
+ decoding="async",
+ width="100%",
+ height="100%",
+ ),
),
rx.vstack(
rx.hstack(
diff --git a/docs/vars/hybrid_properties.md b/docs/vars/hybrid_properties.md
index 0799129a568..b45a8d1bc14 100644
--- a/docs/vars/hybrid_properties.md
+++ b/docs/vars/hybrid_properties.md
@@ -39,7 +39,7 @@ class NameState(rx.State):
def hybrid_full_name_example():
return rx.vstack(
- rx.heading(NameState.full_name),
+ rx.heading(NameState.full_name, as_="h3"),
rx.input(value=NameState.first_name, on_change=NameState.set_first_name),
rx.input(value=NameState.last_name, on_change=NameState.set_last_name),
)
@@ -99,7 +99,7 @@ class GreetState(rx.State):
def hybrid_greeting_example():
return rx.vstack(
- rx.heading(GreetState.greeting),
+ rx.heading(GreetState.greeting, as_="h3"),
rx.input(value=GreetState.name, on_change=GreetState.set_name),
)
```
diff --git a/news/+lazy-dynamic-library-registry.performance.md b/news/+lazy-dynamic-library-registry.performance.md
new file mode 100644
index 00000000000..b010d2415a5
--- /dev/null
+++ b/news/+lazy-dynamic-library-registry.performance.md
@@ -0,0 +1 @@
+Honor `frontend_lazy_bundled_libraries` when compiling the app root so optional dynamic-component namespaces do not force their full exports into every page's initial bundle.
diff --git a/news/+preload-global-css.performance.md b/news/+preload-global-css.performance.md
new file mode 100644
index 00000000000..6c0291c7894
--- /dev/null
+++ b/news/+preload-global-css.performance.md
@@ -0,0 +1 @@
+Preload the global stylesheet so browsers can discover render-blocking CSS alongside early resource hints.
diff --git a/news/+preserve-mounted-prerender.bugfix.md b/news/+preserve-mounted-prerender.bugfix.md
new file mode 100644
index 00000000000..2d4a15aac96
--- /dev/null
+++ b/news/+preserve-mounted-prerender.bugfix.md
@@ -0,0 +1 @@
+Preserve prerendered pages when asset directories collide with routes under `frontend_path`, and compress the final merged output.
diff --git a/packages/reflex-base/news/+docs-loading.performance.md b/packages/reflex-base/news/+docs-loading.performance.md
new file mode 100644
index 00000000000..c2d3bdef0fc
--- /dev/null
+++ b/packages/reflex-base/news/+docs-loading.performance.md
@@ -0,0 +1 @@
+Allow unused memoized components to be removed from shared frontend bundles. Render readable code before Shiki loads, and highlight blocks as they approach the viewport. Changed highlighting options immediately restore the readable fallback until new highlighting succeeds.
diff --git a/packages/reflex-base/news/+lazy-dynamic-library-registry.performance.md b/packages/reflex-base/news/+lazy-dynamic-library-registry.performance.md
new file mode 100644
index 00000000000..d481f57ecc6
--- /dev/null
+++ b/packages/reflex-base/news/+lazy-dynamic-library-registry.performance.md
@@ -0,0 +1 @@
+Add the opt-in `frontend_lazy_bundled_libraries` config setting to load optional dynamic-component libraries on first use, reducing JavaScript loaded by ordinary pages. React and the shared runtime remain immediately available.
diff --git a/packages/reflex-base/news/+project-config-isolation.bugfix.md b/packages/reflex-base/news/+project-config-isolation.bugfix.md
new file mode 100644
index 00000000000..61f1ff98b1f
--- /dev/null
+++ b/packages/reflex-base/news/+project-config-isolation.bugfix.md
@@ -0,0 +1 @@
+Load `rxconfig` only from the requested project directory, preventing an installed or editable app from supplying another project's configuration when no local config exists.
diff --git a/packages/reflex-base/news/+sitemap-protocol.bugfix.md b/packages/reflex-base/news/+sitemap-protocol.bugfix.md
new file mode 100644
index 00000000000..eddd69df08d
--- /dev/null
+++ b/packages/reflex-base/news/+sitemap-protocol.bugfix.md
@@ -0,0 +1 @@
+Use the standard sitemap XML namespace and include `frontend_path` in default sitemap URLs.
diff --git a/packages/reflex-base/src/reflex_base/.templates/web/components/shiki/code.js b/packages/reflex-base/src/reflex_base/.templates/web/components/shiki/code.js
index 8d721dd8a29..eae8d696563 100644
--- a/packages/reflex-base/src/reflex_base/.templates/web/components/shiki/code.js
+++ b/packages/reflex-base/src/reflex_base/.templates/web/components/shiki/code.js
@@ -1,40 +1,106 @@
-import { useEffect, useState, createElement } from "react";
-import { codeToHtml } from "shiki";
+import { useEffect, useRef, useState, createElement } from "react";
-/**
- * Code component that uses Shiki to convert code to HTML and render it.
- *
- * @param code - The code to be highlighted.
- * @param theme - The theme to be used for highlighting.
- * @param language - The language of the code.
- * @param transformers - The transformers to be applied to the code.
- * @param decorations - The decorations to be applied to the code.
- * @param divProps - Additional properties to be passed to the div element.
- * @returns The rendered code block.
- */
+/** Render readable code during SSR and highlight only near the viewport. */
export function Code({
code,
theme,
+ themes,
language,
transformers,
decorations,
...divProps
}) {
- const [codeResult, setCodeResult] = useState("");
+ const container = useRef(null);
+ const [highlighted, setHighlighted] = useState(null);
+
useEffect(() => {
- async function fetchCode() {
- const result = await codeToHtml(code, {
- lang: language,
- theme,
- transformers,
- decorations,
- });
- setCodeResult(result);
+ let active = true;
+ let observer;
+ let idle;
+ const highlight = () => {
+ const run = async () => {
+ try {
+ const { codeToHtml } = await import("shiki");
+ const html = await codeToHtml(code, {
+ lang: language,
+ ...(themes ? { themes } : { theme }),
+ transformers,
+ decorations,
+ });
+ if (active) {
+ setHighlighted({
+ code,
+ language,
+ theme,
+ themes,
+ transformers,
+ decorations,
+ html,
+ });
+ }
+ } catch (error) {
+ // Unsupported grammars or a failed download must leave code readable.
+ console.warn("Unable to highlight code block", error);
+ }
+ };
+ if ("requestIdleCallback" in window) {
+ idle = window.requestIdleCallback(run, { timeout: 1000 });
+ } else {
+ idle = window.setTimeout(run, 0);
+ }
+ };
+ if ("IntersectionObserver" in window) {
+ observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer.disconnect();
+ highlight();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(container.current);
+ } else {
+ highlight();
}
- fetchCode();
- }, [code, language, theme, transformers, decorations]);
- return createElement("div", {
- dangerouslySetInnerHTML: { __html: codeResult },
- ...divProps,
- });
+ return () => {
+ active = false;
+ observer?.disconnect();
+ if ("cancelIdleCallback" in window) window.cancelIdleCallback(idle);
+ else window.clearTimeout(idle);
+ };
+ }, [code, language, theme, themes, transformers, decorations]);
+
+ if (
+ highlighted?.code === code &&
+ highlighted.language === language &&
+ highlighted.theme === theme &&
+ highlighted.themes === themes &&
+ highlighted.transformers === transformers &&
+ highlighted.decorations === decorations
+ ) {
+ return createElement("div", {
+ ...divProps,
+ ref: container,
+ dangerouslySetInnerHTML: { __html: highlighted.html },
+ });
+ }
+ return createElement(
+ "div",
+ { ...divProps, ref: container },
+ createElement(
+ "pre",
+ { className: "shiki", tabIndex: 0 },
+ createElement(
+ "code",
+ null,
+ ...code
+ .split("\n")
+ .flatMap((line, index) => [
+ index > 0 ? "\n" : null,
+ createElement("span", { className: "line", key: index }, line),
+ ]),
+ ),
+ ),
+ );
}
diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
index 617908dec79..abbb515fd40 100644
--- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
+++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
@@ -165,6 +165,7 @@ export const applyDelta = (state, delta) => {
* @returns The evaluated component.
*/
export const evalReactComponent = async (component) => {
+ await window.__reflex_load?.();
if (!window.React && window.__reflex) {
window.React = window.__reflex.react;
}
diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py
index a4e2ff725c4..77582aef64a 100644
--- a/packages/reflex-base/src/reflex_base/compiler/templates.py
+++ b/packages/reflex-base/src/reflex_base/compiler/templates.py
@@ -173,6 +173,7 @@ def app_root_template(
render: dict[str, Any],
dynamic_imports: set[str],
hydrate_fallback_export: str | None = None,
+ lazy_window_libraries: list[tuple[str, str]] | None = None,
):
"""Template for the App root.
@@ -184,6 +185,7 @@ def app_root_template(
render: The dictionary of render functions.
dynamic_imports: The set of dynamic imports.
hydrate_fallback_export: The exported name of the hydrate-fallback memo module to re-export as ``HydrateFallback``, or None for no fallback.
+ lazy_window_libraries: Optional libraries loaded before evaluating a dynamic component.
Returns:
Rendered App root component as string.
@@ -209,6 +211,44 @@ def app_root_template(
f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries
])
+ window_imports_effect = f"""useEffect(() => {{
+ // Make contexts and state objects available globally for dynamic eval'd components
+ window.__reflex = {{
+ {window_imports_str}
+ }};
+ }}, []);"""
+ lazy_imports_setup = ""
+ if lazy_window_libraries:
+ loaders = "\n".join(
+ f" {json.dumps(lib_path)}: () => import({json.dumps(lib_path)}),"
+ for _, lib_path in lazy_window_libraries
+ )
+ # Register before child effects run, including an initially mounted
+ # dynamic component. Optional namespaces stay out of the initial graph.
+ window_imports_effect = ""
+ lazy_imports_setup = f"""
+if (typeof window !== "undefined") {{
+ window.__reflex = {{ ...window.__reflex,
+ {window_imports_str}
+ }};
+ const loaders = {{
+{loaders}
+ }};
+ let pending;
+ window.__reflex_load = () => {{
+ if (!pending) {{
+ pending = Promise.all(Object.entries(loaders).map(async ([name, load]) => {{
+ window.__reflex[name] = await load();
+ }})).catch((error) => {{
+ pending = undefined;
+ throw error;
+ }});
+ }}
+ return pending;
+ }};
+}}
+"""
+
return f"""
{imports_str}
{dynamic_imports_str}
@@ -217,17 +257,12 @@ def app_root_template(
import {{ Layout as AppLayout }} from './_document';
import {{ Outlet }} from 'react-router';
{import_window_libraries}
+{lazy_imports_setup}
{custom_code_str}
function ReflexProviders({{children}}) {{
- useEffect(() => {{
- // Make contexts and state objects available globally for dynamic eval'd components
- let windowImports = {{
- {window_imports_str}
- }};
- window["__reflex"] = windowImports;
- }}, []);
+ {window_imports_effect}
return jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}},
jsx(AppWrap, {{}}, children)
@@ -941,6 +976,16 @@ def _render_memo_component(component: dict[str, Any]) -> str:
# which is the layer that knows the memo's clean export name — the JS symbol
# here carries a module hash and would make a poor label.
display_name = json.dumps(component["display_name"])
+ if component.get("pure_wrapper"):
+ # Keep the label assignment inside the pure initializer, so bundlers
+ # can remove the entire unused export from a shared component module.
+ return (
+ f"\nexport const {name} = /*#__PURE__*/ (() => {{\n"
+ f"const {name} = {export_expr};\n"
+ f"{name}.displayName = {display_name};\n"
+ f"return {name};\n"
+ "})();\n"
+ )
return (
f"\nexport const {name} = {export_expr};\n"
f"{name}.displayName = {display_name};\n"
diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py
index b2e574ef425..bc3061435d1 100644
--- a/packages/reflex-base/src/reflex_base/config.py
+++ b/packages/reflex-base/src/reflex_base/config.py
@@ -9,7 +9,7 @@
import urllib.parse
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
-from importlib.util import find_spec
+from importlib.machinery import PathFinder
from pathlib import Path, PureWindowsPath
from types import ModuleType
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
@@ -177,6 +177,7 @@ class BaseConfig:
react_strict_mode: Whether to use React strict mode.
frontend_compression_formats: Pre-compressed frontend asset formats to generate for production builds. Supported values are "gzip", "brotli", and "zstd". Use an empty list to disable build-time pre-compression.
frontend_packages: Additional frontend packages to install.
+ frontend_lazy_bundled_libraries: Load optional dynamic-component libraries when a dynamic component is first evaluated, rather than importing their full namespaces on every page. Defaults to False for compatibility with scripts that read window.__reflex directly.
state_manager_mode: Indicate which type of state manager to use.
redis_lock_expiration: Maximum expiration lock time for redis state manager.
redis_lock_warning_threshold: Maximum lock time before warning for redis state manager.
@@ -243,6 +244,8 @@ class BaseConfig:
frontend_packages: list[str] = dataclasses.field(default_factory=list)
+ frontend_lazy_bundled_libraries: bool = False
+
state_manager_mode: constants.StateManagerMode = constants.StateManagerMode.DISK
redis_lock_expiration: int = constants.Expiration.LOCK
@@ -953,18 +956,15 @@ def _get_config(project_root: Path | None = None) -> Config:
# Never cache rxconfig or its project-local dependencies — each load
# goes to disk so different RegistrationContexts hold independent
# Config instances resolved against the current project. Evict
- # before probing: find_spec answers from sys.modules, so modules
- # left behind by another project directory would fake the existence
- # check below.
+ # before importing so an earlier project cannot supply the module.
sys.modules.pop(constants.Config.MODULE, None)
for dep in _config_module_deps:
sys.modules.pop(dep, None)
_config_module_deps.clear()
- # only import the module if it exists. If a module spec exists then
- # the module exists.
- if not find_spec(constants.Config.MODULE):
- # we need this condition to ensure that a ModuleNotFound error is not thrown when
- # running unit/integration tests or during `reflex init`.
+ # Only the requested project may supply rxconfig; searching all of
+ # sys.path can pick up an unrelated editable app during reflex init.
+ # PathFinder also supports a project-local rxconfig package.
+ if PathFinder.find_spec(constants.Config.MODULE, [cwd]) is None:
return Config(app_name="", _skip_plugins_checks=True)
with _record_imports() as recorder:
try:
diff --git a/packages/reflex-base/src/reflex_base/plugins/sitemap.py b/packages/reflex-base/src/reflex_base/plugins/sitemap.py
index 700f6587e09..195d3208378 100644
--- a/packages/reflex-base/src/reflex_base/plugins/sitemap.py
+++ b/packages/reflex-base/src/reflex_base/plugins/sitemap.py
@@ -98,7 +98,7 @@ def generate_xml(links: Sequence[SitemapLink]) -> str:
Returns:
A pretty-printed XML string representing the sitemap.
"""
- urlset = Element("urlset", xmlns="https://www.sitemaps.org/schemas/sitemap/0.9")
+ urlset = Element("urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
for link in links:
url = SubElement(urlset, "url")
@@ -150,7 +150,8 @@ def generate_links_for_sitemap(
"""
from reflex_base.config import get_config
- deploy_url = get_config().deploy_url
+ app_config = get_config()
+ deploy_url = app_config.deploy_url
links: list[SitemapLink] = []
@@ -191,8 +192,11 @@ def generate_links_for_sitemap(
else:
loc = page.route if page.route != "index" else "/"
- if not loc.startswith("/"):
- loc = "/" + loc
+ loc = (
+ f"/{app_config.frontend_path.strip('/')}/{loc.lstrip('/')}"
+ if app_config.frontend_path
+ else f"/{loc.lstrip('/')}"
+ )
sitemap_link = configuration_with_loc(
config=sitemap_config,
deploy_url=deploy_url,
diff --git a/packages/reflex-components-code/news/+accessible-copy-buttons.bugfix.md b/packages/reflex-components-code/news/+accessible-copy-buttons.bugfix.md
new file mode 100644
index 00000000000..8f6cb280cd7
--- /dev/null
+++ b/packages/reflex-components-code/news/+accessible-copy-buttons.bugfix.md
@@ -0,0 +1 @@
+Give default code-copy buttons an accessible name and prevent them from submitting an enclosing form.
diff --git a/packages/reflex-components-code/src/reflex_components_code/code.py b/packages/reflex-components-code/src/reflex_components_code/code.py
index 03a2f43b66d..c550c1135d9 100644
--- a/packages/reflex-components-code/src/reflex_components_code/code.py
+++ b/packages/reflex-components-code/src/reflex_components_code/code.py
@@ -485,6 +485,8 @@ def create(
if copy_button is not None
else Button.create(
Icon.create(tag="copy"),
+ aria_label="Copy code",
+ type="button",
on_click=set_clipboard(code),
style=Style({"position": "absolute", "top": "0.5em", "right": "0"}),
)
diff --git a/packages/reflex-components-code/src/reflex_components_code/shiki_code_block.py b/packages/reflex-components-code/src/reflex_components_code/shiki_code_block.py
index 45c2a38eea6..7558f1b2007 100644
--- a/packages/reflex-components-code/src/reflex_components_code/shiki_code_block.py
+++ b/packages/reflex-components-code/src/reflex_components_code/shiki_code_block.py
@@ -785,6 +785,8 @@ def create(
if copy_button is not None
else Button.create(
Icon.create(tag="copy", size=16, color=color("gray", 11)),
+ aria_label="Copy code",
+ type="button",
on_click=[
set_clipboard(cls._strip_transformer_triggers(code)),
copy_script(),
diff --git a/packages/reflex-components-core/news/+sticky-accessible-name.bugfix.md b/packages/reflex-components-core/news/+sticky-accessible-name.bugfix.md
new file mode 100644
index 00000000000..7c4092bfc38
--- /dev/null
+++ b/packages/reflex-components-core/news/+sticky-accessible-name.bugfix.md
@@ -0,0 +1 @@
+Give the Built with Reflex badge an accessible name when its visual text is hidden on small screens.
diff --git a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py
index f4f7312e341..1e41773302b 100644
--- a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py
+++ b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py
@@ -98,6 +98,7 @@ def create(cls):
StickyLogo.create(),
desktop_only(StickyLabel.create()),
href=_badge_href(),
+ aria_label="Built with Reflex",
target="_blank",
width="auto",
padding="0.375rem",
diff --git a/packages/reflex-components-internal/news/+input-clear-label.bugfix.md b/packages/reflex-components-internal/news/+input-clear-label.bugfix.md
new file mode 100644
index 00000000000..952111710f8
--- /dev/null
+++ b/packages/reflex-components-internal/news/+input-clear-label.bugfix.md
@@ -0,0 +1 @@
+Label the input clear button for assistive technology.
diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/base/input.py b/packages/reflex-components-internal/src/reflex_components_internal/components/base/input.py
index 880b43a4246..941915709d0 100644
--- a/packages/reflex-components-internal/src/reflex_components_internal/components/base/input.py
+++ b/packages/reflex-components-internal/src/reflex_components_internal/components/base/input.py
@@ -177,6 +177,7 @@ def _create_clear_button(id: str, clear_events: list[EventHandler]) -> Button:
"""
return Button.create(
hi("CancelCircleIcon"),
+ aria_label="Clear input",
type="button",
on_click=[
set_value(id, ""),
diff --git a/packages/reflex-site-shared/README.md b/packages/reflex-site-shared/README.md
index 19eb16deb78..a2ad14309e2 100644
--- a/packages/reflex-site-shared/README.md
+++ b/packages/reflex-site-shared/README.md
@@ -103,3 +103,7 @@ The default renderer uses the same `reflex-docgen` pipeline as Reflex's main
documentation, including executable example fences, directives, tables, and
the shared documentation component map. Generated component API pages can call
the exported `render_docgen_document` helper directly.
+
+### Expensive documentation previews
+
+Add `defer` to a `python demo exec` fence to mount a heavy preview as it approaches the viewport. The example's source code remains in the initial HTML; the preview reserves 450px of height and stays mounted once shown. This is useful for pages containing many Plotly charts.
diff --git a/packages/reflex-site-shared/news/+accessible-feedback-triggers.bugfix.md b/packages/reflex-site-shared/news/+accessible-feedback-triggers.bugfix.md
new file mode 100644
index 00000000000..98831bfdf42
--- /dev/null
+++ b/packages/reflex-site-shared/news/+accessible-feedback-triggers.bugfix.md
@@ -0,0 +1 @@
+Make each documentation feedback choice an individual popover button, removing invalid ARIA attributes and an extra focus stop from the shared wrapper.
diff --git a/packages/reflex-site-shared/news/+code-contrast.bugfix.md b/packages/reflex-site-shared/news/+code-contrast.bugfix.md
new file mode 100644
index 00000000000..c8db3966c66
--- /dev/null
+++ b/packages/reflex-site-shared/news/+code-contrast.bugfix.md
@@ -0,0 +1 @@
+Use high-contrast syntax highlighting that follows light and dark mode for shared documentation code blocks and command output.
diff --git a/packages/reflex-site-shared/news/+defer-heavy-previews.performance.md b/packages/reflex-site-shared/news/+defer-heavy-previews.performance.md
new file mode 100644
index 00000000000..6016bc83d82
--- /dev/null
+++ b/packages/reflex-site-shared/news/+defer-heavy-previews.performance.md
@@ -0,0 +1 @@
+Support `python demo exec defer` blocks that mount expensive previews near the viewport while preserving the example code in server-rendered HTML.
diff --git a/packages/reflex-site-shared/news/+status-contrast.bugfix.md b/packages/reflex-site-shared/news/+status-contrast.bugfix.md
new file mode 100644
index 00000000000..01811a98fe0
--- /dev/null
+++ b/packages/reflex-site-shared/news/+status-contrast.bugfix.md
@@ -0,0 +1 @@
+Improve the contrast of operational status text in site footers.
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py
index bfba7acd038..a8fa1bea2d1 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py
@@ -8,6 +8,9 @@
EXPAND_THRESHOLD_LINES = 20
COLLAPSED_MAX_HEIGHT = "400px"
+DOCS_CODE_THEME = rx.color_mode_cond(
+ light="github-light-high-contrast", dark="github-dark-high-contrast"
+)
@rx.memo
@@ -21,6 +24,7 @@ def _plain_code_block(code: rx.Var[str], language: rx.Var[str]) -> rx.Component:
shiki_code_block(
code,
language=language,
+ theme=DOCS_CODE_THEME,
class_name="code-block",
can_copy=True,
),
@@ -93,6 +97,7 @@ def code_block_dark(code: rx.Var[str], language: rx.Var[str]) -> rx.Component:
shiki_code_block(
code,
language=language,
+ theme=DOCS_CODE_THEME,
class_name="code-block",
can_copy=True,
),
@@ -139,7 +144,7 @@ def doccmdoutput(
can_copy=True,
border_radius=styles.DOC_BORDER_RADIUS,
background="transparent",
- theme="ayu-dark",
+ theme=DOCS_CODE_THEME,
language="bash",
code_tag_props={
"style": {
@@ -155,7 +160,7 @@ def doccmdoutput(
can_copy=False,
border_radius="12px",
background="transparent",
- theme="ayu-dark",
+ theme=DOCS_CODE_THEME,
language="log",
code_tag_props={
"style": {
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/demo.py b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/demo.py
index 9332083f940..c33abe4bb6c 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/demo.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/demo.py
@@ -17,6 +17,13 @@
_DATA_TAB_VALUE = "data"
+class DeferredDemo(rx.Component):
+ """Mount a costly interactive preview when it approaches the viewport."""
+
+ library = "$/public/components/DeferredDemo"
+ tag = "DeferredDemo"
+
+
def _reflex_build_icon() -> rx.Component:
"""Create the Reflex Build mark for the demo action.
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/docs_shell.py b/packages/reflex-site-shared/src/reflex_site_shared/components/docs_shell.py
index 5d9b3079e2e..2bc83de793c 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/components/docs_shell.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/components/docs_shell.py
@@ -403,7 +403,9 @@ def _feedback_content() -> rx.Component:
max_length=100,
),
ui.popover.close(
- ui.button("Send feedback", type="submit", class_name="w-full")
+ render_=ui.button(
+ "Send feedback", type="submit", class_name="w-full"
+ )
),
class_name="w-full gap-4 flex flex-col",
),
@@ -425,22 +427,24 @@ def docs_feedback_button() -> rx.Component:
"""
shared_class = "flex w-full cursor-pointer flex-row items-center justify-center gap-2 whitespace-nowrap border px-3 py-0.5 font-small transition-colors"
return ui.popover.root(
- ui.popover.trigger(
- render_=rx.el.div(
- _feedback_choice_button(
+ rx.el.div(
+ ui.popover.trigger(
+ render_=_feedback_choice_button(
"Yes",
"ThumbsUpIcon",
1,
ui.cn("rounded-[20px_0_0_20px] border-r-0", shared_class),
),
- _feedback_choice_button(
+ ),
+ ui.popover.trigger(
+ render_=_feedback_choice_button(
"No",
"ThumbsDownIcon",
0,
ui.cn("rounded-[0_20px_20px_0]", shared_class),
),
- class_name="flex w-full flex-row items-center lg:w-auto",
),
+ class_name="flex w-full flex-row items-center lg:w-auto",
),
ui.popover.portal(ui.popover.positioner(ui.popover.popup(_feedback_content()))),
)
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py b/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py
index 60f16e44c8c..dd86c9c2b21 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py
@@ -11,7 +11,7 @@
DEFAULT_CLASS_NAME = "inline-flex flex-row gap-1.5 items-center font-medium text-sm px-2.5 rounded-[10px] h-9 hover:bg-secondary-3 transition-bg"
STATUS_TEXT_COLORS: dict[StatusVariant, str] = {
- "Success": "text-success-9",
+ "Success": "text-success-11",
"Warning": "text-warning-11",
"Critical": "text-destructive-10",
}
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/docs/markdown.py b/packages/reflex-site-shared/src/reflex_site_shared/docs/markdown.py
index cf19a2005ae..95bebe3918a 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/docs/markdown.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/docs/markdown.py
@@ -43,7 +43,12 @@
import reflex as rx
from reflex_site_shared.components.blocks.code import code_block
from reflex_site_shared.components.blocks.collapsible import collapsible_box
-from reflex_site_shared.components.blocks.demo import docdemo, docdemobox, docgraphing
+from reflex_site_shared.components.blocks.demo import (
+ DeferredDemo,
+ docdemo,
+ docdemobox,
+ docgraphing,
+)
from reflex_site_shared.components.blocks.headings import (
h1_comp_xd,
h2_comp_xd,
@@ -459,6 +464,9 @@ def _render_demo(self, content: str, flags: set[str]) -> rx.Component:
)
raise
+ if "defer" in flags:
+ comp = DeferredDemo.create(comp)
+
demobox_props: dict = {}
for flag in flags:
k, sep, v = flag.partition("=")
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/plugins.py b/packages/reflex-site-shared/src/reflex_site_shared/plugins.py
index 6104e87db0e..5c57ce5a259 100644
--- a/packages/reflex-site-shared/src/reflex_site_shared/plugins.py
+++ b/packages/reflex-site-shared/src/reflex_site_shared/plugins.py
@@ -19,6 +19,7 @@
_FONT_STYLESHEET = "fonts.css"
_PUBLIC_ASSETS = (
"components/AlgoliaSearch.tsx",
+ "components/DeferredDemo.jsx",
"components/GradientButton.tsx",
"icons/search.svg",
)
diff --git a/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/DeferredDemo.jsx b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/DeferredDemo.jsx
new file mode 100644
index 00000000000..16c4528cd0a
--- /dev/null
+++ b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/DeferredDemo.jsx
@@ -0,0 +1,47 @@
+import { createElement, useEffect, useRef, useState } from "react";
+
+/** Mount expensive previews once, as readers approach them. */
+export function DeferredDemo({ children, style, ...props }) {
+ const element = useRef(null);
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ if (!("IntersectionObserver" in window)) {
+ setReady(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setReady(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(element.current);
+ return () => observer.disconnect();
+ }, []);
+ return createElement(
+ "div",
+ {
+ ...props,
+ ref: element,
+ style: { width: "100%", minHeight: "450px", ...style },
+ },
+ ready
+ ? children
+ : createElement(
+ "p",
+ {
+ style: {
+ minHeight: "450px",
+ display: "grid",
+ placeItems: "center",
+ color: "var(--secondary-11)",
+ fontSize: "0.875rem",
+ },
+ },
+ "Interactive preview loads as you scroll.",
+ ),
+ );
+}
diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py
index 2304a74e02f..52bc887469b 100644
--- a/reflex/compiler/compiler.py
+++ b/reflex/compiler/compiler.py
@@ -32,7 +32,7 @@
from reflex_base.constants.state import FIELD_MARKER
from reflex_base.environment import environment
from reflex_base.plugins import CompileContext, CompilerHooks, PageContext, Plugin
-from reflex_base.registry import RegistrationContext
+from reflex_base.registry import RegistrationContext, _default_bundled_libraries
from reflex_base.utils import log, memo_paths
from reflex_base.utils.exceptions import ReflexError
from reflex_base.utils.format import to_title_case
@@ -152,6 +152,19 @@ def _compile_app(
]
window_libraries_deduped = list(dict.fromkeys(window_libraries))
+ lazy_window_libraries = []
+ if get_config().frontend_lazy_bundled_libraries:
+ core_libraries = set(_default_bundled_libraries())
+ lazy_window_libraries = [
+ library
+ for library in window_libraries_deduped
+ if library[1] not in core_libraries
+ ]
+ window_libraries_deduped = [
+ library
+ for library in window_libraries_deduped
+ if library[1] in core_libraries
+ ]
app_root_imports = app_root._get_all_imports()
_apply_common_imports(app_root_imports)
@@ -161,6 +174,7 @@ def _compile_app(
custom_codes=app_root._get_all_custom_code(),
hooks=app_root._get_all_hooks(),
window_libraries=window_libraries_deduped,
+ lazy_window_libraries=lazy_window_libraries,
render=app_root.render(),
dynamic_imports=app_root._get_all_dynamic_imports(),
hydrate_fallback_export=hydrate_fallback_export,
diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py
index c9908b5e843..ef437f8d39e 100644
--- a/reflex/compiler/utils.py
+++ b/reflex/compiler/utils.py
@@ -19,6 +19,7 @@
from reflex_base import constants
from reflex_base.components.component import BaseComponent, Component, ComponentStyle
from reflex_base.components.memo import (
+ DEFAULT_MEMO_WRAPPER,
MemoComponentDefinition,
MemoFunctionDefinition,
MemoParamKind,
@@ -463,6 +464,8 @@ def compile_experimental_component_memo(
rest=rest_param.placeholder_name if rest_param is not None else None,
).to_javascript(),
"wrapper": str(wrapper) if wrapper is not None else None,
+ "pure_wrapper": wrapper is not None
+ and wrapper.equals(DEFAULT_MEMO_WRAPPER),
"render": rendered,
"hooks": hooks,
"custom_code": custom_code,
@@ -630,22 +633,26 @@ def create_document_root(
):
existing_meta_types.add("viewport")
+ global_styles_href = Var(
+ "reflexGlobalStyles",
+ _var_data=VarData(
+ imports={
+ "$/styles/__reflex_global_styles.css?url": [
+ ImportVar(tag="reflexGlobalStyles", is_default=True)
+ ]
+ }
+ ),
+ )
# Always include the framework meta and link tags.
always_head_components = [
ReactMeta.create(),
+ Link.create(
+ rel="preload", custom_attrs={"as": "style"}, href=global_styles_href
+ ),
Link.create(
rel="stylesheet",
type="text/css",
- href=Var(
- "reflexGlobalStyles",
- _var_data=VarData(
- imports={
- "$/styles/__reflex_global_styles.css?url": [
- ImportVar(tag="reflexGlobalStyles", is_default=True)
- ]
- }
- ),
- ),
+ href=global_styles_href,
),
Links.create(),
]
diff --git a/reflex/utils/build.py b/reflex/utils/build.py
index b9282816e8a..5046f33d78d 100644
--- a/reflex/utils/build.py
+++ b/reflex/utils/build.py
@@ -228,6 +228,24 @@ def _compress_static_output(directory: Path, formats: tuple[str, ...]) -> None:
raise SystemExit(1)
+def _merge_static_output(source: Path, destination: Path) -> None:
+ """Move assets into a route tree without overwriting prerendered pages.
+
+ Args:
+ source: An asset file or directory emitted outside the frontend prefix.
+ destination: Its location in the final static output.
+ """
+ if source.is_dir() and destination.is_dir():
+ for child in source.iterdir():
+ _merge_static_output(child, destination / child.name)
+ source.rmdir()
+ elif destination.exists():
+ # In particular, the root SPA shell must not replace the rendered index.
+ path_ops.rm(source)
+ else:
+ source.rename(destination)
+
+
def build():
"""Build the app for deployment.
@@ -282,11 +300,6 @@ def build():
if spa_fallback.exists():
path_ops.cp(spa_fallback, static_dir / "404.html")
- _compress_static_output(
- static_dir,
- tuple(config.frontend_compression_formats),
- )
-
if frontend_path := config.frontend_path.strip("/"):
# Create a subdirectory that matches the configured frontend_path.
frontend_path = PurePosixPath(frontend_path)
@@ -297,7 +310,12 @@ def build():
for child in list(static_dir.iterdir()):
if child.is_dir() and child.name == first_part:
continue
- path_ops.mv(child, prefix_dir / child.name)
+ _merge_static_output(child, prefix_dir / child.name)
+
+ _compress_static_output(
+ static_dir,
+ tuple(config.frontend_compression_formats),
+ )
def setup_frontend(
diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py
index f784ce35a6d..7d45204ffa5 100644
--- a/tests/units/compiler/test_compiler.py
+++ b/tests/units/compiler/test_compiler.py
@@ -462,8 +462,12 @@ def test_compile_app_root_with_hydrate_fallback_exports_hydrate_fallback():
)
-def test_compile_app_root_includes_radix_window_library_when_bundled():
+def test_compile_app_root_includes_radix_window_library_when_bundled(mocker):
"""Bundled Radix libraries should be exposed to window.__reflex."""
+ mocker.patch(
+ "reflex.compiler.compiler.get_config",
+ return_value=rx.Config(app_name="eager_libraries"),
+ )
reset_bundled_libraries()
try:
bundle_library("@radix-ui/themes@3.3.0")
@@ -476,6 +480,25 @@ def test_compile_app_root_includes_radix_window_library_when_bundled():
reset_bundled_libraries()
+def test_compile_app_root_can_defer_optional_window_libraries(mocker):
+ """Dynamic libraries need not force their entire exports into every page."""
+ mocker.patch(
+ "reflex.compiler.compiler.get_config",
+ return_value=rx.Config(
+ app_name="lazy_libraries", frontend_lazy_bundled_libraries=True
+ ),
+ )
+ with RegistrationContext():
+ bundle_library("@radix-ui/themes@3.3.0")
+ _, code = compiler.compile_app_root(rx.el.div("hello"))
+
+ assert 'import * as radix_ui_themes from "@radix-ui/themes"' not in code
+ assert '() => import("@radix-ui/themes")' in code
+ assert 'import * as React from "react"' in code
+ assert 'import * as utils_context from "$/utils/context"' in code
+ assert "window.__reflex_load" in code
+
+
def _mock_config_color_mode(mocker: MockerFixture, mode: LiteralColorMode) -> None:
"""Point the compiler's get_config at a fresh config with the given mode.
@@ -595,7 +618,7 @@ def test_create_document_root():
assert isinstance(lang, LiteralStringVar)
assert lang.equals(Var.create("en"))
# No children in head.
- assert len(root.children[0].children) == 6
+ assert len(root.children[0].children) == 7
assert isinstance(root.children[0].children[1], Meta)
char_set = root.children[0].children[1].char_set # pyright: ignore [reportAttributeAccessIssue]
assert isinstance(char_set, LiteralStringVar)
@@ -606,7 +629,8 @@ def test_create_document_root():
assert name.equals(Var.create("viewport"))
assert isinstance(root.children[0].children[3], document.Meta)
assert isinstance(root.children[0].children[4], Link)
- assert isinstance(root.children[0].children[5], Links)
+ assert isinstance(root.children[0].children[5], Link)
+ assert isinstance(root.children[0].children[6], Links)
def test_create_document_root_with_scripts():
@@ -621,7 +645,7 @@ def test_create_document_root_with_scripts():
html_custom_attrs={"project": "reflex"},
)
assert isinstance(root, Html)
- assert len(root.children[0].children) == 8
+ assert len(root.children[0].children) == 9
names = [c.tag for c in root.children[0].children]
assert names == [
"script",
@@ -631,6 +655,7 @@ def test_create_document_root_with_scripts():
"meta",
"Meta",
"link",
+ "link",
"Links",
]
lang = root.lang # pyright: ignore [reportAttributeAccessIssue]
@@ -649,9 +674,9 @@ def test_create_document_root_with_meta_char_set():
head_components=comps,
)
assert isinstance(root, Html)
- assert len(root.children[0].children) == 6
+ assert len(root.children[0].children) == 7
names = [c.tag for c in root.children[0].children]
- assert names == ["script", "meta", "meta", "Meta", "link", "Links"]
+ assert names == ["script", "meta", "meta", "Meta", "link", "link", "Links"]
assert str(root.children[0].children[1].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue]
@@ -665,9 +690,9 @@ def test_create_document_root_with_meta_viewport():
head_components=comps,
)
assert isinstance(root, Html)
- assert len(root.children[0].children) == 7
+ assert len(root.children[0].children) == 8
names = [c.tag for c in root.children[0].children]
- assert names == ["script", "meta", "meta", "meta", "Meta", "link", "Links"]
+ assert names == ["script", "meta", "meta", "meta", "Meta", "link", "link", "Links"]
assert str(root.children[0].children[1].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue]
assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue]
assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue]
diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py
index c5c15fb3ee9..6d1553ff506 100644
--- a/tests/units/compiler/test_compiler_utils.py
+++ b/tests/units/compiler/test_compiler_utils.py
@@ -5,6 +5,7 @@
import pytest
from reflex_components_core.base.fragment import Fragment
from reflex_components_core.base.script import Script
+from reflex_components_core.el.elements.metadata import Link
from reflex.compiler.utils import compile_state, create_document_root
from reflex.constants.state import FIELD_MARKER
@@ -12,6 +13,20 @@
from reflex.vars.base import computed_var
+def test_document_preloads_the_global_stylesheet():
+ """Render-blocking CSS should be discoverable alongside early resource hints."""
+ head = create_document_root().children[0]
+ links = [
+ child.render()["props"] for child in head.children if isinstance(child, Link)
+ ]
+ preload = next(props for props in links if 'rel:"preload"' in props)
+ stylesheet = next(props for props in links if 'rel:"stylesheet"' in props)
+ assert next(prop for prop in preload if prop.startswith("href:")) == next(
+ prop for prop in stylesheet if prop.startswith("href:")
+ )
+ assert 'as:"style"' in preload
+
+
class CompileStateState(State):
"""State fixture exercising async computed vars during compile_state."""
diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py
index c7b8615423d..8661d7714ab 100644
--- a/tests/units/compiler/test_memoize_plugin.py
+++ b/tests/units/compiler/test_memoize_plugin.py
@@ -964,8 +964,8 @@ def find_emitted(suffix: str) -> str | None:
matched_b = find_emitted("memo_collision_test/module_b.jsx")
assert matched_a is not None, f"missing module_a memo file in {sorted(emitted)}"
assert matched_b is not None, f"missing module_b memo file in {sorted(emitted)}"
- assert f"export const {symbol_a} = memo" in matched_a
- assert f"export const {symbol_b} = memo" in matched_b
+ assert f"const {symbol_a} = memo" in matched_a
+ assert f"const {symbol_b} = memo" in matched_b
def test_shared_parent_instance_across_pages_preserves_original() -> None:
diff --git a/tests/units/components/datadisplay/test_code.py b/tests/units/components/datadisplay/test_code.py
index 9c185ccdb65..cd9e451ac97 100644
--- a/tests/units/components/datadisplay/test_code.py
+++ b/tests/units/components/datadisplay/test_code.py
@@ -4,6 +4,13 @@
import reflex as rx
+def test_default_code_copy_button_has_an_accessible_name():
+ """Icon-only copy controls must announce their action."""
+ assert '"aria-label":"Copy code"' in str(
+ CodeBlock.create("print('Hello')", can_copy=True)
+ )
+
+
@pytest.mark.parametrize(
("theme", "expected"),
[(Theme.one_light, "oneLight"), (Theme.one_dark, "oneDark")],
diff --git a/tests/units/components/datadisplay/test_shiki_code.py b/tests/units/components/datadisplay/test_shiki_code.py
index b405127ee5e..8353c7e9e93 100644
--- a/tests/units/components/datadisplay/test_shiki_code.py
+++ b/tests/units/components/datadisplay/test_shiki_code.py
@@ -13,6 +13,13 @@
from reflex_components_radix.themes.layout.box import Box
+def test_default_shiki_copy_button_has_an_accessible_name():
+ """Icon-only Shiki copy controls must announce their action."""
+ assert '"aria-label":"Copy code"' in str(
+ ShikiHighLevelCodeBlock.create("print('Hello')", can_copy=True)
+ )
+
+
@pytest.mark.parametrize(
("library", "fns", "expected_output", "raises_exception"),
[
diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py
index 457f12e3744..9c89352a66b 100644
--- a/tests/units/components/test_memo.py
+++ b/tests/units/components/test_memo.py
@@ -132,7 +132,7 @@ def my_card(
files, _ = compiler.compile_memo_components(tuple(MEMOS.values()))
code = "\n".join(c for _, c in files)
- assert f"export const {sym} = memo(" in code
+ assert f"const {sym} = memo(" in code
assert "({children, title:title" in code
assert "...rest" in code
assert "jsx(RadixThemesBox,{...rest}" in code
@@ -158,7 +158,7 @@ def conditional_slot(
sym = memo_paths.mirrored_symbol("ConditionalSlot", __name__)
files, _ = compiler.compile_memo_components(tuple(MEMOS.values()))
code = "\n".join(c for _, c in files)
- assert f"export const {sym} = memo(" in code
+ assert f"const {sym} = memo(" in code
assert "({show:showRxMemo" in code
assert "(showRxMemo ? firstRxMemo : secondRxMemo)" in code
@@ -1112,9 +1112,9 @@ def my_card(children: rx.Var[rx.Component], *, title: rx.Var[str]) -> rx.Compone
text_wrapper_sym = memo_paths.mirrored_symbol("TextWrapper", __name__)
format_price_sym = memo_paths.mirrored_symbol("format_price", __name__)
my_card_sym = memo_paths.mirrored_symbol("MyCard", __name__)
- assert f"export const {text_wrapper_sym} = memo(" in code
+ assert f"const {text_wrapper_sym} = memo(" in code
assert f"export const {format_price_sym} =" in code
- assert f"export const {my_card_sym} = memo(" in code
+ assert f"const {my_card_sym} = memo(" in code
def test_compile_memo_components_groups_by_source_module():
@@ -1141,8 +1141,8 @@ def grouped_second(title: rx.Var[str]) -> rx.Component:
code = grouped_files[0][1]
first_sym = memo_paths.mirrored_symbol("GroupedFirst", __name__)
second_sym = memo_paths.mirrored_symbol("GroupedSecond", __name__)
- assert f"export const {first_sym} = memo(" in code
- assert f"export const {second_sym} = memo(" in code
+ assert f"const {first_sym} = memo(" in code
+ assert f"const {second_sym} = memo(" in code
# The merged module must carry imports its memos use, not just the
# framework-level ones added by the compiler.
assert "RadixThemesText" in code
@@ -1189,7 +1189,8 @@ def default_wrapped(label: rx.Var[str]) -> rx.Component:
files, imports = compiler.compile_memo_components((definition,))
code = "\n".join(c for _, c in files)
sym = memo_paths.mirrored_symbol("DefaultWrapped", __name__)
- assert f"export const {sym} = memo(({{label:labelRxMemo}}) => {{" in code
+ assert f"export const {sym} = /*#__PURE__*/ (() => {{" in code
+ assert f"const {sym} = memo(({{label:labelRxMemo}}) => {{" in code
assert any(imp.tag == "memo" for imp in imports.get("react", []))
@@ -2321,3 +2322,19 @@ class _BetaProbe(Component):
assert alpha.render() == beta.render()
assert memo_tag(alpha) != memo_tag(beta)
+
+
+def test_custom_wrapper_named_memo_is_not_treated_as_react_memo():
+ """A custom wrapper may share React's name and still have side effects."""
+ wrapper = FunctionStringVar.create(
+ "memo", _var_data=VarData(imports={"tracking-library": [ImportVar(tag="memo")]})
+ )
+
+ @rx.memo(wrapper=wrapper)
+ def tracked_named_memo() -> rx.Component:
+ return rx.text("Tracked")
+
+ definition = MEMOS["TrackedNamedMemo", __name__]
+ files, _ = compiler.compile_memo_components((definition,))
+ code = "\n".join(content for _, content in files)
+ assert "/*#__PURE__*/" not in code
diff --git a/tests/units/components/test_memo_cross_module.py b/tests/units/components/test_memo_cross_module.py
index 9b7a6dc6673..a5b92409f8c 100644
--- a/tests/units/components/test_memo_cross_module.py
+++ b/tests/units/components/test_memo_cross_module.py
@@ -160,8 +160,8 @@ def test_memo_depends_on_memo_across_modules_in_grouped_file():
# group's own self-import is stripped), so the two never redeclare a symbol.
assert sym_a != sym_c
assert f'import {{{sym_a}}} from "{lib_a}"' in code_c
- assert f"export const {sym_consumer} = memo(" in code_c
- assert f"export const {sym_c} = memo(" in code_c
+ assert f"const {sym_consumer} = memo(" in code_c
+ assert f"const {sym_c} = memo(" in code_c
def test_three_modules_sharing_a_name_all_compile():
diff --git a/tests/units/plugins/test_sitemap.py b/tests/units/plugins/test_sitemap.py
index e15a814d429..f5c13f55419 100644
--- a/tests/units/plugins/test_sitemap.py
+++ b/tests/units/plugins/test_sitemap.py
@@ -36,7 +36,7 @@ def test_generate_xml_empty_links():
"""Test generate_xml with an empty list of links."""
xml_output = generate_xml([])
expected = """
- """
+ """
assert xml_output == expected
@@ -45,7 +45,7 @@ def test_generate_xml_single_link_loc_only():
links: list[SitemapLink] = [{"loc": "https://example.com"}]
xml_output = generate_xml(links)
expected = """
-
+
https://example.com
@@ -72,7 +72,7 @@ def test_generate_xml_multiple_links_all_fields():
]
xml_output = generate_xml(links)
expected = """
-
+
https://example.com/page1
daily
@@ -99,6 +99,7 @@ def test_generate_links_for_sitemap_static_routes(
mock_get_config: Mock for the get_config function.
caplog: Pytest log capture fixture.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -158,6 +159,7 @@ def test_generate_links_for_sitemap_dynamic_routes(
mock_get_config: Mock for the get_config function.
caplog: Pytest log capture fixture.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://sub.example.org"
now = datetime.datetime(2023, 6, 13, 12, 0, 0)
@@ -228,6 +230,7 @@ def test_generate_links_for_sitemap_404_route(
mock_get_config: Mock for the get_config function.
caplog: Pytest log capture fixture.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = None # No deploy URL
def mock_component():
@@ -273,6 +276,7 @@ def test_generate_links_for_sitemap_opt_out(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = None # No deploy URL
def mock_component():
@@ -312,6 +316,7 @@ def test_generate_links_for_sitemap_loc_override(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "http://localhost:3000"
def mock_component():
@@ -352,6 +357,7 @@ def test_generate_links_for_sitemap_priority_clamping(mock_get_config: MagicMock
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -406,6 +412,7 @@ def test_generate_links_for_sitemap_no_deploy_url(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = None
def mock_component():
@@ -459,6 +466,7 @@ def test_generate_links_for_sitemap_deploy_url_trailing_slash(
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com/"
def mock_component():
@@ -488,6 +496,7 @@ def test_generate_links_for_sitemap_loc_leading_slash(mock_get_config: MagicMock
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -517,6 +526,7 @@ def test_generate_links_for_sitemap_loc_full_url(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -546,6 +556,7 @@ def test_generate_links_trailing_slash_always(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -597,6 +608,7 @@ def test_generate_links_trailing_slash_never(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -651,6 +663,7 @@ def test_generate_links_trailing_slash_never_no_deploy_url_index(
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = None
def mock_component():
@@ -692,6 +705,7 @@ def test_generate_links_trailing_slash_preserve(mock_get_config: MagicMock):
Args:
mock_get_config: Mock for the get_config function.
"""
+ mock_get_config.return_value.frontend_path = ""
mock_get_config.return_value.deploy_url = "https://example.com"
def mock_component():
@@ -725,3 +739,17 @@ def mock_component():
assert {"loc": "https://example.com/about"} in links
# Existing trailing slash preserved on docs
assert {"loc": "https://example.com/docs/"} in links
+
+
+@patch("reflex_base.config.get_config")
+@pytest.mark.parametrize("frontend_path", ["/docs", "/guide/docs/"])
+def test_default_sitemap_locations_include_frontend_path(
+ mock_get_config, frontend_path
+):
+ """Mounted apps must advertise their public home and article URLs."""
+ mock_get_config.return_value.deploy_url = "https://example.com"
+ mock_get_config.return_value.frontend_path = frontend_path
+ pages = [MagicMock(route=route, context={}) for route in ("index", "intro")]
+ links = generate_links_for_sitemap(pages, trailing_slash="always")
+ base = "https://example.com/" + frontend_path.strip("/")
+ assert links == [{"loc": base + "/"}, {"loc": base + "/intro/"}]
diff --git a/tests/units/reflex_components_core/core/test_sticky.py b/tests/units/reflex_components_core/core/test_sticky.py
index 257201f2553..8bca734feb5 100644
--- a/tests/units/reflex_components_core/core/test_sticky.py
+++ b/tests/units/reflex_components_core/core/test_sticky.py
@@ -37,3 +37,8 @@ def test_badge_href_empty_referrer(monkeypatch: pytest.MonkeyPatch):
"""An empty referrer param falls back to the default URL."""
monkeypatch.setenv("REFLEX_REFERRER_PARAM", "")
assert _badge_href() == "https://reflex.dev"
+
+
+def test_badge_has_accessible_name_when_visual_label_is_hidden():
+ """Mobile readers can identify the logo-only link."""
+ assert '"aria-label":"Built with Reflex"' in StickyBadge.create().render()["props"]
diff --git a/tests/units/reflex_components_internal/components/base/test_input.py b/tests/units/reflex_components_internal/components/base/test_input.py
new file mode 100644
index 00000000000..9cbaae6170a
--- /dev/null
+++ b/tests/units/reflex_components_internal/components/base/test_input.py
@@ -0,0 +1,8 @@
+"""Accessibility checks for input controls."""
+
+from reflex_components_internal.components.base.input import HighLevelInput
+
+
+def test_clear_button_has_an_accessible_name():
+ """The icon-only clear button announces its purpose."""
+ assert '"aria-label":"Clear input"' in str(HighLevelInput.create(id="email"))
diff --git a/tests/units/reflex_site_shared/test_docs_shell.py b/tests/units/reflex_site_shared/test_docs_shell.py
index bd7e4cce3ca..5fa8f78ae41 100644
--- a/tests/units/reflex_site_shared/test_docs_shell.py
+++ b/tests/units/reflex_site_shared/test_docs_shell.py
@@ -5,6 +5,7 @@
import pytest
from reflex_site_shared.components.docs_shell import (
_docs_external_page_footer_memo,
+ docs_feedback_button,
docs_feedback_button_toc,
docs_left_sidebar,
docs_page_footer,
@@ -18,13 +19,22 @@
import reflex as rx
+def test_feedback_choices_are_individual_popover_triggers():
+ """Each feedback choice is a real button, without an interactive div parent."""
+ choices = docs_feedback_button().children[0]
+ assert choices.tag == "div"
+ assert len(choices.children) == 2
+ assert all("Trigger" in (child.tag or "") for child in choices.children)
+
+
def test_shared_feedback_preserves_the_official_form_structure() -> None:
- """Keep the official feedback form DOM and thumb-button props unchanged."""
+ """Keep the feedback form layout and accessible clear control."""
rendered = str(docs_feedback_button_toc())
assert "w-full gap-4 flex flex-col" in rendered
assert "flex flex-col gap-4 w-full" in rendered
- assert "aria-label" not in rendered
+ assert '"aria-label":"Clear input"' in rendered
+ assert 'jsx(Popover.Close,{"data-slot":"popover-close",render:' in rendered
def test_docs_layout_rejects_conflicting_footer_factories() -> None:
diff --git a/tests/units/reflex_site_shared/test_plugins.py b/tests/units/reflex_site_shared/test_plugins.py
index e280f1f5725..e4d3565dcd7 100644
--- a/tests/units/reflex_site_shared/test_plugins.py
+++ b/tests/units/reflex_site_shared/test_plugins.py
@@ -37,14 +37,16 @@ def test_shared_site_styles_plugin_emits_package_css():
Path("styles/reflex-site-shared/tailwind-theme.css"),
Path("styles/reflex-site-shared/fonts.css"),
Path("public/components/AlgoliaSearch.tsx"),
+ Path("public/components/DeferredDemo.jsx"),
Path("public/components/GradientButton.tsx"),
Path("public/icons/search.svg"),
]
assert all(content.strip() for _path, content in assets)
assert "ph-conversations-widget" in assets[1][1]
assert "export function AlgoliaSearch" in assets[3][1]
- assert "export function GradientButton" in assets[4][1]
- assert "