From c7a48ad5db0c0d028fb089979465a0eb7fb783ea Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:16:10 +0100 Subject: [PATCH 1/4] Improve deferred Spline preview loading (ENG-13557) --- .../+wrapping-react-preview.performance.md | 1 + docs/app/pyproject.toml | 2 +- docs/app/tests/test_editorial_browser.py | 86 +++++++++++++++++++ docs/app/tests/test_overview_pages.py | 16 ++++ docs/wrapping-react/overview.md | 7 +- packages/reflex-site-shared/README.md | 7 ++ .../news/+idle-demos.performance.md | 1 + .../components/blocks/demo.py | 2 +- .../styles/assets/components/DeferredDemo.jsx | 45 +++++++--- .../reflex_site_shared/docs/test_markdown.py | 27 ++++++ 10 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 docs/app/news/+wrapping-react-preview.performance.md create mode 100644 packages/reflex-site-shared/news/+idle-demos.performance.md diff --git a/docs/app/news/+wrapping-react-preview.performance.md b/docs/app/news/+wrapping-react-preview.performance.md new file mode 100644 index 00000000000..312b3602a88 --- /dev/null +++ b/docs/app/news/+wrapping-react-preview.performance.md @@ -0,0 +1 @@ +Load the Wrapping React overview's Spline runtime and 3D preview automatically when the preview is visible and the browser has idle time. diff --git a/docs/app/pyproject.toml b/docs/app/pyproject.toml index d89369e758d..1d2dd941b2d 100644 --- a/docs/app/pyproject.toml +++ b/docs/app/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "reflex-pyplot", "reflex-integrations-docs", "reflex-components-internal", - "reflex-site-shared", + "reflex-site-shared>=0.0.49.dev0", "alembic", "requests", "ruff-format", diff --git a/docs/app/tests/test_editorial_browser.py b/docs/app/tests/test_editorial_browser.py index 0a3e92c4295..faaeec7ee10 100644 --- a/docs/app/tests/test_editorial_browser.py +++ b/docs/app/tests/test_editorial_browser.py @@ -11,6 +11,92 @@ ) +@pytest.mark.parametrize("width", [390, 1440]) +def test_spline_preview_waits_for_visibility_and_idle(page: Page, width: int): + """Load the 3D preview only while visible, after the browser becomes idle.""" + page.set_viewport_size({"width": width, "height": 600}) + page.add_init_script(""" + window.previewIdleCallbacks = new Map(); + let nextId = 0; + window.requestIdleCallback = callback => { + const id = ++nextId; + window.previewIdleCallbacks.set(id, callback); + return id; + }; + window.cancelIdleCallback = id => window.previewIdleCallbacks.delete(id); + """) + scripts = [] + scenes = [] + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.on( + "request", + lambda request: ( + scripts.append(request.url) + if request.resource_type == "script" and request.url.startswith(PREVIEW_URL) + else None + ), + ) + page.on( + "request", + lambda request: ( + scenes.append(request.url) if "scene.splinecode" in request.url else None + ), + ) + page.goto(f"{PREVIEW_URL}/docs/wrapping-react/overview/", wait_until="networkidle") + preview = page.locator(".docs-demo-preview").first + assert preview.bounding_box()["y"] >= 600 + assert scenes == [] + assert scripts + assert all( + "renderSplineScene" not in page.request.get(url).text() for url in scripts + ) + preview.scroll_into_view_if_needed() + page.wait_for_function("window.previewIdleCallbacks.size > 0") + assert scenes == [] + page.evaluate("window.scrollTo(0, 0)") + page.wait_for_function("window.previewIdleCallbacks.size === 0") + + tabs = page.locator('[role="tablist"]').first + tabs.get_by_role("tab", name="Code", exact=True).click() + expect( + page.get_by_text("class Spline(NoSSRComponent):", exact=False) + ).to_be_visible() + page.wait_for_function("window.previewIdleCallbacks.size === 0") + tabs.get_by_role("tab", name="View", exact=True).click() + preview.scroll_into_view_if_needed() + page.wait_for_function("window.previewIdleCallbacks.size > 0") + assert scenes == [] + assert all( + "renderSplineScene" not in page.request.get(url).text() for url in scripts + ) + + with page.expect_response("**/scene.splinecode") as scene: + page.evaluate("""() => { + for (const [id, callback] of window.previewIdleCallbacks) { + window.previewIdleCallbacks.delete(id); + callback({didTimeout: false, timeRemaining: () => 50}); + } + }""") + assert scene.value.ok + expect(page.locator(".docs-demo-preview canvas")).to_be_visible() + assert errors == [] + + +@pytest.mark.parametrize("idle_callback", [True, False]) +def test_spline_preview_loads_automatically(page: Page, idle_callback: bool): + """Scrolling loads the preview with native idle callbacks or their fallback.""" + page.set_viewport_size({"width": 390, "height": 600}) + if not idle_callback: + page.add_init_script("delete window.requestIdleCallback") + page.goto(f"{PREVIEW_URL}/docs/wrapping-react/overview/", wait_until="networkidle") + expect(page.locator(".docs-demo-preview canvas")).to_have_count(0) + with page.expect_response("**/scene.splinecode") as scene: + page.locator(".docs-demo-preview").first.scroll_into_view_if_needed() + assert scene.value.ok + expect(page.locator(".docs-demo-preview canvas")).to_be_visible() + + @pytest.mark.parametrize("width", [320, 375, 768, 1440]) @pytest.mark.parametrize("route", ["/docs/", "/docs/getting-started/introduction/"]) def test_editorial_header_stays_in_viewport(page: Page, width: int, route: str): diff --git a/docs/app/tests/test_overview_pages.py b/docs/app/tests/test_overview_pages.py index 2c7a14b19e0..7a50d3c83af 100644 --- a/docs/app/tests/test_overview_pages.py +++ b/docs/app/tests/test_overview_pages.py @@ -32,6 +32,22 @@ ] +def test_wrapping_overview_defers_the_spline_import_and_preview(): + """Reading the overview must not import or mount the 3D demo eagerly.""" + body, _ = render_docgen_document( + "docs/wrapping-react/overview.md", DOCS / "wrapping-react/overview.md" + ) + imports = body._get_all_imports() + spline_imports = imports["@splinetool/react-spline@4.1.0"] + assert all(not item.render for item in spline_imports) + assert any( + "import('@splinetool/react-spline')" in statement + for statement in body._get_all_dynamic_imports() + ) + assert "DeferredDemo" in str(body) + assert "loadOnClick" not in str(body) + + @pytest.mark.parametrize("path", PAGES) def test_overview_faq_schema_matches_visible_answers(path): """Every structured answer must also appear in the visible FAQ.""" diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index c6f75158665..bc4f3abcdc2 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -17,11 +17,14 @@ Simple components that don't have any interaction can be wrapped with just a few Below we show how to wrap the [Spline](https://github.com/splinetool/react-spline) library can be used to create 3D scenes and animations. -```python demo exec +The 3D preview loads automatically as you scroll to it. The wrapper uses `NoSSRComponent` to import Spline when it mounts in the browser. + +```python demo exec defer import reflex as rx +from reflex.components.component import NoSSRComponent -class Spline(rx.Component): +class Spline(NoSSRComponent): """Spline component.""" # The name of the npm package. diff --git a/packages/reflex-site-shared/README.md b/packages/reflex-site-shared/README.md index d04dd97f0ab..9d94e4110e5 100644 --- a/packages/reflex-site-shared/README.md +++ b/packages/reflex-site-shared/README.md @@ -63,6 +63,13 @@ config = rx.Config( Existing sites that provide their own font CSS can use `SharedSiteStylesPlugin(include_fonts=False)`. +Executable `python demo exec` fences can add `defer` to mount their preview when +it is visible and the browser has idle time. Pending loads are canceled when +readers scroll away or switch to the Code tab. +Use `NoSSRComponent` for expensive wrapped libraries so their JavaScript is +also imported only when the preview mounts. The Code tab stays available before +the preview loads. + `DocsMarkdownPlugin` serves each discovered page as Markdown using the same URL convention as the official Reflex docs. For example, `/guide/installation/` is also available at `/guide/installation.md` and diff --git a/packages/reflex-site-shared/news/+idle-demos.performance.md b/packages/reflex-site-shared/news/+idle-demos.performance.md new file mode 100644 index 00000000000..748a227353d --- /dev/null +++ b/packages/reflex-site-shared/news/+idle-demos.performance.md @@ -0,0 +1 @@ +Load deferred documentation previews when they are visible and the browser has idle time, canceling pending loads when readers scroll away. 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 58ccded52c4..9410207d36f 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 @@ -18,7 +18,7 @@ class DeferredDemo(rx.Component): - """Mount a costly interactive preview when it approaches the viewport.""" + """Mount a costly interactive preview when visible and the browser is idle.""" library = "$/public/components/DeferredDemo" tag = "DeferredDemo" 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 index 6774f1350f4..e2e0f290cec 100644 --- 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 @@ -1,25 +1,42 @@ import { createElement, useEffect, useRef, useState } from "react"; -/** Mount expensive previews once, as readers approach them. */ +/** Mount expensive previews when visible and the browser has idle time. */ export function DeferredDemo({ children, style, ...props }) { const element = useRef(null); const [ready, setReady] = useState(false); useEffect(() => { - if (!("IntersectionObserver" in window)) { + let observer; + let scheduled = null; + const useIdleCallback = "requestIdleCallback" in window; + const cancel = () => { + if (scheduled === null) return; + if (useIdleCallback) window.cancelIdleCallback(scheduled); + else window.clearTimeout(scheduled); + scheduled = null; + }; + const load = () => { + scheduled = null; + observer?.disconnect(); setReady(true); - return; + }; + const schedule = () => { + scheduled ??= useIdleCallback + ? window.requestIdleCallback(load, { timeout: 2000 }) + : window.setTimeout(load, 0); + }; + if ("IntersectionObserver" in window) { + observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) schedule(); + else cancel(); + }); + observer.observe(element.current); + } else { + schedule(); } - 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 () => { + observer?.disconnect(); + cancel(); + }; }, []); return createElement( "div", diff --git a/tests/units/reflex_site_shared/docs/test_markdown.py b/tests/units/reflex_site_shared/docs/test_markdown.py index 10ffc8f089a..b2ffae05277 100644 --- a/tests/units/reflex_site_shared/docs/test_markdown.py +++ b/tests/units/reflex_site_shared/docs/test_markdown.py @@ -3,6 +3,8 @@ from pathlib import Path import pytest +from reflex_base.components.component import BaseComponent +from reflex_site_shared.components.blocks.demo import DeferredDemo from reflex_site_shared.docs.markdown import ( get_docgen_toc, get_markdown_toc, @@ -31,6 +33,31 @@ def example(): assert "Shared live example" in str(component) +def test_deferred_demo_preserves_code_and_gates_preview() -> None: + """Automatic loading must gate the live preview without hiding its code.""" + component = render_markdown( + """```python demo exec defer +import reflex as rx + +def example(): + return rx.text("Activated preview") +```""", + virtual_filepath="tests/deferred-demo.md", + ) + pending: list[BaseComponent] = [component] + previews = [] + while pending: + child = pending.pop() + if isinstance(child, DeferredDemo): + previews.append(child) + pending.extend(child.children) + + assert len(previews) == 1 + assert "loadOnClick" not in str(previews[0]) + assert "Activated preview" in str(previews[0]) + assert "rx.text" in str(component) + + def test_render_markdown_covers_core_blocks_and_spans() -> None: """Render every core block type with inline span formatting.""" component = render_markdown( From f8629a7dd5b95d09970b04f8891c1c51eb7af056 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:35:34 +0100 Subject: [PATCH 2/4] Restore deferred demo prefetch margin and remount loaded previews Keep the 200px IntersectionObserver margin so deferred previews start loading just before they scroll in, and remember loaded demos by a stable per-page id so switching back from the Code tab remounts them without another idle wait. Merge the README defer docs into the existing section, use the public rx.NoSSRComponent in the Wrapping React example, and tighten the tests: assert the tab switch cancels a pending load, replace the renderSplineScene content check with a script-request count, and drop stale loadOnClick assertions. Co-Authored-By: Claude Opus 5.5 --- docs/app/tests/test_editorial_browser.py | 39 ++++++++------- docs/app/tests/test_overview_pages.py | 9 +++- docs/wrapping-react/overview.md | 5 +- packages/reflex-site-shared/README.md | 11 ++--- .../news/+idle-demos.performance.md | 2 +- .../components/blocks/demo.py | 3 ++ .../src/reflex_site_shared/docs/markdown.py | 8 +++- .../styles/assets/components/DeferredDemo.jsx | 24 ++++++---- .../reflex_site_shared/docs/test_markdown.py | 47 +++++++++++++++---- 9 files changed, 100 insertions(+), 48 deletions(-) diff --git a/docs/app/tests/test_editorial_browser.py b/docs/app/tests/test_editorial_browser.py index faaeec7ee10..0bdd3db8b19 100644 --- a/docs/app/tests/test_editorial_browser.py +++ b/docs/app/tests/test_editorial_browser.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize("width", [390, 1440]) def test_spline_preview_waits_for_visibility_and_idle(page: Page, width: int): - """Load the 3D preview only while visible, after the browser becomes idle.""" + """Load the 3D preview only near the viewport, after the browser becomes idle.""" page.set_viewport_size({"width": width, "height": 600}) page.add_init_script(""" window.previewIdleCallbacks = new Map(); @@ -45,31 +45,30 @@ def test_spline_preview_waits_for_visibility_and_idle(page: Page, width: int): ) page.goto(f"{PREVIEW_URL}/docs/wrapping-react/overview/", wait_until="networkidle") preview = page.locator(".docs-demo-preview").first - assert preview.bounding_box()["y"] >= 600 - assert scenes == [] - assert scripts - assert all( - "renderSplineScene" not in page.request.get(url).text() for url in scripts - ) + pending = "window.previewIdleCallbacks.size" + # Beyond the viewport and the observer's 200px margin. + assert preview.bounding_box()["y"] >= 800 + assert page.evaluate(pending) == 0 + initial_scripts = len(scripts) + preview.scroll_into_view_if_needed() - page.wait_for_function("window.previewIdleCallbacks.size > 0") - assert scenes == [] + page.wait_for_function(f"{pending} > 0") page.evaluate("window.scrollTo(0, 0)") - page.wait_for_function("window.previewIdleCallbacks.size === 0") + page.wait_for_function(f"{pending} === 0") + preview.scroll_into_view_if_needed() + page.wait_for_function(f"{pending} > 0") tabs = page.locator('[role="tablist"]').first tabs.get_by_role("tab", name="Code", exact=True).click() + page.wait_for_function(f"{pending} === 0") expect( - page.get_by_text("class Spline(NoSSRComponent):", exact=False) + page.get_by_text("class Spline(rx.NoSSRComponent):", exact=False) ).to_be_visible() - page.wait_for_function("window.previewIdleCallbacks.size === 0") tabs.get_by_role("tab", name="View", exact=True).click() preview.scroll_into_view_if_needed() - page.wait_for_function("window.previewIdleCallbacks.size > 0") + page.wait_for_function(f"{pending} > 0") assert scenes == [] - assert all( - "renderSplineScene" not in page.request.get(url).text() for url in scripts - ) + assert len(scripts) == initial_scripts with page.expect_response("**/scene.splinecode") as scene: page.evaluate("""() => { @@ -80,6 +79,14 @@ def test_spline_preview_waits_for_visibility_and_idle(page: Page, width: int): }""") assert scene.value.ok expect(page.locator(".docs-demo-preview canvas")).to_be_visible() + # The deferred import fetched the wrapper's chunk only after the idle callback. + assert len(scripts) > initial_scripts + + # A loaded preview remounts without waiting for idle time again. + tabs.get_by_role("tab", name="Code", exact=True).click() + tabs.get_by_role("tab", name="View", exact=True).click() + expect(page.locator(".docs-demo-preview canvas")).to_be_visible() + assert page.evaluate(pending) == 0 assert errors == [] diff --git a/docs/app/tests/test_overview_pages.py b/docs/app/tests/test_overview_pages.py index 7a50d3c83af..f3fa7bd02f5 100644 --- a/docs/app/tests/test_overview_pages.py +++ b/docs/app/tests/test_overview_pages.py @@ -38,14 +38,19 @@ def test_wrapping_overview_defers_the_spline_import_and_preview(): "docs/wrapping-react/overview.md", DOCS / "wrapping-react/overview.md" ) imports = body._get_all_imports() - spline_imports = imports["@splinetool/react-spline@4.1.0"] + spline_imports = [ + item + for library, items in imports.items() + if library.startswith("@splinetool/react-spline@") + for item in items + ] + assert spline_imports assert all(not item.render for item in spline_imports) assert any( "import('@splinetool/react-spline')" in statement for statement in body._get_all_dynamic_imports() ) assert "DeferredDemo" in str(body) - assert "loadOnClick" not in str(body) @pytest.mark.parametrize("path", PAGES) diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index bc4f3abcdc2..d39f5c180c2 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -17,14 +17,13 @@ Simple components that don't have any interaction can be wrapped with just a few Below we show how to wrap the [Spline](https://github.com/splinetool/react-spline) library can be used to create 3D scenes and animations. -The 3D preview loads automatically as you scroll to it. The wrapper uses `NoSSRComponent` to import Spline when it mounts in the browser. +The 3D preview loads automatically as you scroll to it. The wrapper uses [`rx.NoSSRComponent`](/docs/wrapping-react/library-and-tags/) so Spline is imported only when it mounts in the browser. ```python demo exec defer import reflex as rx -from reflex.components.component import NoSSRComponent -class Spline(NoSSRComponent): +class Spline(rx.NoSSRComponent): """Spline component.""" # The name of the npm package. diff --git a/packages/reflex-site-shared/README.md b/packages/reflex-site-shared/README.md index 9d94e4110e5..47bd424908e 100644 --- a/packages/reflex-site-shared/README.md +++ b/packages/reflex-site-shared/README.md @@ -63,13 +63,6 @@ config = rx.Config( Existing sites that provide their own font CSS can use `SharedSiteStylesPlugin(include_fonts=False)`. -Executable `python demo exec` fences can add `defer` to mount their preview when -it is visible and the browser has idle time. Pending loads are canceled when -readers scroll away or switch to the Code tab. -Use `NoSSRComponent` for expensive wrapped libraries so their JavaScript is -also imported only when the preview mounts. The Code tab stays available before -the preview loads. - `DocsMarkdownPlugin` serves each discovered page as Markdown using the same URL convention as the official Reflex docs. For example, `/guide/installation/` is also available at `/guide/installation.md` and @@ -113,7 +106,9 @@ 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. +Add `defer` to a `python demo exec` fence to mount a heavy preview once it is within 200px of the viewport and the browser has idle time. A pending load is canceled when the reader scrolls away or switches to the Code tab, and a preview that has loaded remounts immediately when the reader returns to it. The example's source code remains in the initial HTML, and the preview reserves 450px of height. This is useful for pages containing many Plotly charts. + +Wrap expensive libraries with `rx.NoSSRComponent` so their JavaScript is also imported only when the preview mounts. ## Marketing footer diff --git a/packages/reflex-site-shared/news/+idle-demos.performance.md b/packages/reflex-site-shared/news/+idle-demos.performance.md index 748a227353d..9691d4c89d6 100644 --- a/packages/reflex-site-shared/news/+idle-demos.performance.md +++ b/packages/reflex-site-shared/news/+idle-demos.performance.md @@ -1 +1 @@ -Load deferred documentation previews when they are visible and the browser has idle time, canceling pending loads when readers scroll away. +Load deferred documentation previews as they near the viewport and the browser has idle time, canceling pending loads when readers scroll away and remounting loaded previews immediately after a tab switch. 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 9410207d36f..d3b3ee904ee 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 @@ -23,6 +23,9 @@ class DeferredDemo(rx.Component): library = "$/public/components/DeferredDemo" tag = "DeferredDemo" + # Identifies the demo so it remounts immediately once shown, e.g. after a tab switch. + demo_id: rx.Var[str] + 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/docs/markdown.py b/packages/reflex-site-shared/src/reflex_site_shared/docs/markdown.py index 8e6e0991b0a..b9307ac6f57 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 @@ -3,6 +3,7 @@ # Transformer overrides inherit their contracts from DocumentTransformer. # ruff: noqa: D102, D107, DOC201 +import hashlib import json import re import sys @@ -533,7 +534,12 @@ def _render_demo(self, content: str, flags: set[str]) -> rx.Component: raise if "defer" in flags: - comp = DeferredDemo.create(comp) + comp = DeferredDemo.create( + comp, + demo_id=hashlib.blake2s( + f"{self.virtual_filepath}\0{content}".encode(), digest_size=8 + ).hexdigest(), + ) demobox_props: dict = {} for flag in flags: 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 index e2e0f290cec..31904149b9d 100644 --- 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 @@ -1,10 +1,14 @@ import { createElement, useEffect, useRef, useState } from "react"; -/** Mount expensive previews when visible and the browser has idle time. */ -export function DeferredDemo({ children, style, ...props }) { +// Demos already shown in this session remount at once, e.g. after a tab switch. +const loadedDemos = new Set(); + +/** Mount expensive previews near the viewport once the browser has idle time. */ +export function DeferredDemo({ children, style, demoId, ...props }) { const element = useRef(null); - const [ready, setReady] = useState(false); + const [ready, setReady] = useState(() => loadedDemos.has(demoId)); useEffect(() => { + if (ready) return; let observer; let scheduled = null; const useIdleCallback = "requestIdleCallback" in window; @@ -17,6 +21,7 @@ export function DeferredDemo({ children, style, ...props }) { const load = () => { scheduled = null; observer?.disconnect(); + loadedDemos.add(demoId); setReady(true); }; const schedule = () => { @@ -25,10 +30,13 @@ export function DeferredDemo({ children, style, ...props }) { : window.setTimeout(load, 0); }; if ("IntersectionObserver" in window) { - observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) schedule(); - else cancel(); - }); + observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) schedule(); + else cancel(); + }, + { rootMargin: "200px" }, + ); observer.observe(element.current); } else { schedule(); @@ -37,7 +45,7 @@ export function DeferredDemo({ children, style, ...props }) { observer?.disconnect(); cancel(); }; - }, []); + }, [ready, demoId]); return createElement( "div", { diff --git a/tests/units/reflex_site_shared/docs/test_markdown.py b/tests/units/reflex_site_shared/docs/test_markdown.py index b2ffae05277..7e4a5c254ef 100644 --- a/tests/units/reflex_site_shared/docs/test_markdown.py +++ b/tests/units/reflex_site_shared/docs/test_markdown.py @@ -33,31 +33,60 @@ def example(): assert "Shared live example" in str(component) -def test_deferred_demo_preserves_code_and_gates_preview() -> None: - """Automatic loading must gate the live preview without hiding its code.""" - component = render_markdown( - """```python demo exec defer +_DEFERRED_DEMO = """```python demo exec defer import reflex as rx def example(): return rx.text("Activated preview") -```""", - virtual_filepath="tests/deferred-demo.md", - ) - pending: list[BaseComponent] = [component] +```""" + + +def _deferred_previews( + component: BaseComponent, +) -> list[DeferredDemo]: + """Collect the deferred previews in a rendered component tree. + + Args: + component: The rendered component tree. + + Returns: + The deferred previews, in no particular order. + """ + pending = [component] previews = [] while pending: child = pending.pop() if isinstance(child, DeferredDemo): previews.append(child) pending.extend(child.children) + return previews + + +def test_deferred_demo_preserves_code_and_gates_preview() -> None: + """Automatic loading must gate the live preview without hiding its code.""" + component = render_markdown( + _DEFERRED_DEMO, virtual_filepath="tests/deferred-demo.md" + ) + previews = _deferred_previews(component) assert len(previews) == 1 - assert "loadOnClick" not in str(previews[0]) assert "Activated preview" in str(previews[0]) assert "rx.text" in str(component) +def test_deferred_demo_id_is_stable_per_page_and_source() -> None: + """A demo keeps its id across renders so a loaded preview remounts at once.""" + + def demo_props(path: str) -> str: + (preview,) = _deferred_previews( + render_markdown(_DEFERRED_DEMO, virtual_filepath=path) + ) + return str(preview.render()["props"]) + + assert demo_props("tests/a.md") == demo_props("tests/a.md") + assert demo_props("tests/a.md") != demo_props("tests/b.md") + + def test_render_markdown_covers_core_blocks_and_spans() -> None: """Render every core block type with inline span formatting.""" component = render_markdown( From 2015c00d6d34537ec3b1c85e2e1d1c19ec344f36 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:40:35 +0100 Subject: [PATCH 3/4] Drop reflex-site-shared floor from the docs app The docs app builds with any reflex-site-shared that supports defer; the new loader is picked up from the workspace without a floor. Co-Authored-By: Claude Opus 5.5 --- docs/app/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/app/pyproject.toml b/docs/app/pyproject.toml index 1d2dd941b2d..d89369e758d 100644 --- a/docs/app/pyproject.toml +++ b/docs/app/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "reflex-pyplot", "reflex-integrations-docs", "reflex-components-internal", - "reflex-site-shared>=0.0.49.dev0", + "reflex-site-shared", "alembic", "requests", "ruff-format", From 363d0df1496018add4eb7c5667d3c5b652f540e9 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:44:45 +0100 Subject: [PATCH 4/4] Number deferred demos per page instead of hashing their source Identical defer fences on one page shared an id, so loading one made the other skip its visibility and idle wait. Ids are now the page path plus the fence's position, which stays stable across renders. Co-Authored-By: Claude Opus 5.5 --- .../src/reflex_site_shared/docs/markdown.py | 8 +++---- .../reflex_site_shared/docs/test_markdown.py | 22 +++++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) 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 b9307ac6f57..95d7eabd012 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 @@ -3,7 +3,6 @@ # Transformer overrides inherit their contracts from DocumentTransformer. # ruff: noqa: D102, D107, DOC201 -import hashlib import json import re import sys @@ -247,6 +246,7 @@ def __init__(self, virtual_filepath: str = "", filename: str = "") -> None: self.virtual_filepath = virtual_filepath self.filename = filename self.env: dict = {} + self.deferred_demos = 0 # ------------------------------------------------------------------ # Top-level @@ -534,11 +534,9 @@ def _render_demo(self, content: str, flags: set[str]) -> rx.Component: raise if "defer" in flags: + self.deferred_demos += 1 comp = DeferredDemo.create( - comp, - demo_id=hashlib.blake2s( - f"{self.virtual_filepath}\0{content}".encode(), digest_size=8 - ).hexdigest(), + comp, demo_id=f"{self.virtual_filepath}#{self.deferred_demos}" ) demobox_props: dict = {} diff --git a/tests/units/reflex_site_shared/docs/test_markdown.py b/tests/units/reflex_site_shared/docs/test_markdown.py index 7e4a5c254ef..5b1a6ef1a4d 100644 --- a/tests/units/reflex_site_shared/docs/test_markdown.py +++ b/tests/units/reflex_site_shared/docs/test_markdown.py @@ -74,17 +74,21 @@ def test_deferred_demo_preserves_code_and_gates_preview() -> None: assert "rx.text" in str(component) -def test_deferred_demo_id_is_stable_per_page_and_source() -> None: - """A demo keeps its id across renders so a loaded preview remounts at once.""" - - def demo_props(path: str) -> str: - (preview,) = _deferred_previews( - render_markdown(_DEFERRED_DEMO, virtual_filepath=path) +def test_deferred_demo_id_is_stable_and_unique_per_fence() -> None: + """Each fence keeps its id across renders, even beside an identical fence.""" + + def demo_props(path: str) -> list[str]: + previews = _deferred_previews( + render_markdown( + f"{_DEFERRED_DEMO}\n\n{_DEFERRED_DEMO}", virtual_filepath=path + ) ) - return str(preview.render()["props"]) + return sorted(str(preview.render()["props"]) for preview in previews) - assert demo_props("tests/a.md") == demo_props("tests/a.md") - assert demo_props("tests/a.md") != demo_props("tests/b.md") + first, second = demo_props("tests/a.md") + assert first != second + assert demo_props("tests/a.md") == [first, second] + assert set(demo_props("tests/b.md")).isdisjoint({first, second}) def test_render_markdown_covers_core_blocks_and_spans() -> None: