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/tests/test_editorial_browser.py b/docs/app/tests/test_editorial_browser.py index 0a3e92c4295..0bdd3db8b19 100644 --- a/docs/app/tests/test_editorial_browser.py +++ b/docs/app/tests/test_editorial_browser.py @@ -11,6 +11,99 @@ ) +@pytest.mark.parametrize("width", [390, 1440]) +def test_spline_preview_waits_for_visibility_and_idle(page: Page, width: int): + """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(); + 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 + 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(f"{pending} > 0") + page.evaluate("window.scrollTo(0, 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(rx.NoSSRComponent):", exact=False) + ).to_be_visible() + tabs.get_by_role("tab", name="View", exact=True).click() + preview.scroll_into_view_if_needed() + page.wait_for_function(f"{pending} > 0") + assert scenes == [] + assert len(scripts) == initial_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() + # 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 == [] + + +@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..f3fa7bd02f5 100644 --- a/docs/app/tests/test_overview_pages.py +++ b/docs/app/tests/test_overview_pages.py @@ -32,6 +32,27 @@ ] +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 = [ + 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) + + @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..d39f5c180c2 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -17,11 +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. -```python demo exec +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 -class Spline(rx.Component): +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 d04dd97f0ab..47bd424908e 100644 --- a/packages/reflex-site-shared/README.md +++ b/packages/reflex-site-shared/README.md @@ -106,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 new file mode 100644 index 00000000000..9691d4c89d6 --- /dev/null +++ b/packages/reflex-site-shared/news/+idle-demos.performance.md @@ -0,0 +1 @@ +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 58ccded52c4..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 @@ -18,11 +18,14 @@ 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" + # 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..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 @@ -246,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 @@ -533,7 +534,10 @@ def _render_demo(self, content: str, flags: set[str]) -> rx.Component: raise if "defer" in flags: - comp = DeferredDemo.create(comp) + self.deferred_demos += 1 + comp = DeferredDemo.create( + comp, demo_id=f"{self.virtual_filepath}#{self.deferred_demos}" + ) 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 6774f1350f4..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,26 +1,51 @@ import { createElement, useEffect, useRef, useState } from "react"; -/** Mount expensive previews once, as readers approach them. */ -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 (!("IntersectionObserver" in window)) { + if (ready) return; + 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(); + loadedDemos.add(demoId); 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(); + }, + { rootMargin: "200px" }, + ); + 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(); + }; + }, [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 10ffc8f089a..5b1a6ef1a4d 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,64 @@ def example(): assert "Shared live example" in str(component) +_DEFERRED_DEMO = """```python demo exec defer +import reflex as rx + +def example(): + return rx.text("Activated preview") +```""" + + +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 "Activated preview" in str(previews[0]) + assert "rx.text" in str(component) + + +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 sorted(str(preview.render()["props"]) for preview in previews) + + 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: """Render every core block type with inline span formatting.""" component = render_markdown(