diff --git a/news/6905.bugfix.md b/news/6905.bugfix.md new file mode 100644 index 00000000000..bb6632e2a66 --- /dev/null +++ b/news/6905.bugfix.md @@ -0,0 +1 @@ +`rx.script` head updates now flush synchronously instead of via react-helmet's requestAnimationFrame batching, fixing intermittently missing script tags after hydration (flaky "scripts not loaded" failures). diff --git a/news/6905.performance.md b/news/6905.performance.md new file mode 100644 index 00000000000..f58dca62aa0 --- /dev/null +++ b/news/6905.performance.md @@ -0,0 +1 @@ +Dev mode no longer pays for React's per-element owner-stack capture: navigation clicks in a large app dropped from ~350ms to ~83ms of main-thread CPU (5.6x prod down to ~1.3x). In exchange `React.captureOwnerStack()` returns no owner frames in dev, which affects React DevTools' owner-stack view and custom error overlays built on that API; set `REFLEX_REACT_OWNER_STACKS=1` to restore them. diff --git a/packages/reflex-base/news/6905.performance.md b/packages/reflex-base/news/6905.performance.md new file mode 100644 index 00000000000..f58dca62aa0 --- /dev/null +++ b/packages/reflex-base/news/6905.performance.md @@ -0,0 +1 @@ +Dev mode no longer pays for React's per-element owner-stack capture: navigation clicks in a large app dropped from ~350ms to ~83ms of main-thread CPU (5.6x prod down to ~1.3x). In exchange `React.captureOwnerStack()` returns no owner frames in dev, which affects React DevTools' owner-stack view and custom error overlays built on that API; set `REFLEX_REACT_OWNER_STACKS=1` to restore them. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 59520613899..d8239387903 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -277,6 +277,7 @@ def context_template( initial_state: dict[str, Any] | None = None, state_name: str | None = None, client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, + disable_react_owner_stacks: bool = False, ): """Template for the context file. @@ -286,6 +287,9 @@ def context_template( client_storage: The client storage for the context. is_dev_mode: Whether the app is in development mode. default_color_mode: The default color mode for the context. + disable_react_owner_stacks: Whether to emit the snippet that disables + React's dev-build owner-stack capture (an Error() constructed per + created element, whose cost grows with render depth). Returns: Rendered context file content as string. @@ -358,10 +362,38 @@ def context_template( for state_name in initial_state ) - return rf"""import {{ createContext, useContext, useMemo, useReducer, useState, createElement, useEffect }} from "react" + disable_owner_stacks_str = ( + r""" +// Disable React dev-build owner-stack capture: the per-element Error() +// dominates dev-mode render CPU on large pages. Costs owner frames in +// `React.captureOwnerStack()`; set REFLEX_REACT_OWNER_STACKS=1 to restore. +// Full context: https://github.com/reflex-dev/reflex/pull/6905 +if (typeof window !== "undefined") { + try { + const reactInternals = + React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + const ownerStackCounterKey = "recentlyCreatedOwnerStacks"; + if ( + reactInternals && + typeof reactInternals[ownerStackCounterKey] === "number" + ) { + Object.defineProperty(reactInternals, ownerStackCounterKey, { + get: () => 1e9, + set: () => {}, + configurable: true, + }); + } + } catch {} +} +""" + if disable_react_owner_stacks + else "" + ) + + return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ createContext, useContext, useMemo, useReducer, useState, createElement, useEffect }} from "react" import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" import {{ jsx }} from "@emotion/react"; - +{disable_owner_stacks_str} export const initialState = {"{}" if not initial_state else json_dumps(initial_state)} export const defaultColorMode = {default_color_mode} diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 359effad060..15b6723fa02 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -627,6 +627,12 @@ class EnvironmentVariables: # This env var stores the execution mode of the app REFLEX_ENV_MODE: EnvVar[constants.Env] = env_var(constants.Env.DEV) + # Whether to keep React's development-build owner-stack capture in dev mode. + # Reflex disables it by default because the per-element Error() capture + # dominates dev-mode render CPU on large pages; enable it to restore full + # owner stacks in React DevTools and dev warnings. + REFLEX_REACT_OWNER_STACKS: EnvVar[bool] = env_var(False) + # Whether to run the backend only. Exclusive with REFLEX_FRONTEND_ONLY. REFLEX_BACKEND_ONLY: EnvVar[bool] = env_var(False) diff --git a/packages/reflex-components-core/news/6905.bugfix.md b/packages/reflex-components-core/news/6905.bugfix.md new file mode 100644 index 00000000000..bb6632e2a66 --- /dev/null +++ b/packages/reflex-components-core/news/6905.bugfix.md @@ -0,0 +1 @@ +`rx.script` head updates now flush synchronously instead of via react-helmet's requestAnimationFrame batching, fixing intermittently missing script tags after hydration (flaky "scripts not loaded" failures). diff --git a/packages/reflex-components-core/src/reflex_components_core/base/script.py b/packages/reflex-components-core/src/reflex_components_core/base/script.py index e2a6d19887d..5a022d3c8e1 100644 --- a/packages/reflex-components-core/src/reflex_components_core/base/script.py +++ b/packages/reflex-components-core/src/reflex_components_core/base/script.py @@ -70,7 +70,10 @@ def create( custom_attrs=custom_attrs, on_mount=on_mount, on_unmount=on_unmount, - ) + ), + # Flush head updates synchronously: the default rAF-deferred flush + # can be lost around hydration, dropping the script tags entirely. + defer=False, ) diff --git a/packages/reflex-components-core/src/reflex_components_core/core/helmet.py b/packages/reflex-components-core/src/reflex_components_core/core/helmet.py index 986d6cd5cd4..667bebc2847 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/helmet.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/helmet.py @@ -1,6 +1,7 @@ """Helmet component module.""" from reflex_base.components.component import Component +from reflex_base.vars.base import Var class Helmet(Component): @@ -10,5 +11,8 @@ class Helmet(Component): tag = "Helmet" + # Whether to batch client-side head updates via requestAnimationFrame. + defer: Var[bool] + helmet = Helmet.create diff --git a/pyi_hashes.json b/pyi_hashes.json index f83a2da704d..e6677dcf3c7 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -17,7 +17,7 @@ "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "473666c89f74a1fcd82f12cc2cd34f43", "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "21d51b69ab11279e864f7aca9307462f", "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "86b2bea0aeec5334dbb64b3addae91df", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "cbbfc51195fb5cef671ff9ce65586d29", + "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "a43533eba05dec1ece2602bd64ca0f01", "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "bedb5c94e690d50721ddc7bfdfbe8a25", "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "a910d65f42f620531c959677d22977cb", "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "d903297d188fe6fe56a824127e0db925", diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index f7fcd8c3509..6e8096d05b8 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -211,6 +211,9 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> The compiled context file. """ default_color_mode = str(LiteralVar.create(_resolve_default_color_mode(theme))) + disable_react_owner_stacks = ( + not is_prod_mode() and not environment.REFLEX_REACT_OWNER_STACKS.get() + ) return ( templates.context_template( @@ -219,11 +222,13 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), default_color_mode=default_color_mode, + disable_react_owner_stacks=disable_react_owner_stacks, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), default_color_mode=default_color_mode, + disable_react_owner_stacks=disable_react_owner_stacks, ) ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index b467b0823c7..d6994a100f8 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1387,3 +1387,41 @@ def register_route(self, *, add_page, **_): compiler._register_plugin_routes(app, [ComponentPlugin()]) assert app._unevaluated_pages["component-page"]._source_module == __name__ + + +@pytest.mark.parametrize("disable_owner_stacks", [True, False]) +def test_context_template_owner_stack_pin(disable_owner_stacks: bool): + """The owner-stack pin is emitted only when asked for, and only for the browser. + + The snippet mutates shared React internals, so it must never run in the + server renderer, and it must disappear entirely when owner stacks are + requested (REFLEX_REACT_OWNER_STACKS=1) or in production builds. + + Args: + disable_owner_stacks: Whether the pin should be emitted. + """ + from reflex_base.compiler.templates import context_template + + rendered = context_template( + is_dev_mode=True, + default_color_mode='"light"', + disable_react_owner_stacks=disable_owner_stacks, + ) + + if not disable_owner_stacks: + assert "recentlyCreatedOwnerStacks" not in rendered + # React is only imported for the pin; without it the import is dead weight. + assert not rendered.startswith("import React,") + return + + assert "recentlyCreatedOwnerStacks" in rendered + assert rendered.startswith("import React,") + # Browser-only: the guard must wrap the mutation, not merely precede it. + pin_at = rendered.index("recentlyCreatedOwnerStacks") + guard_at = rendered.index('typeof window !== "undefined"') + assert guard_at < pin_at + assert "Object.defineProperty" in rendered + # The documented escape hatch must be discoverable from the generated code. + assert "REFLEX_REACT_OWNER_STACKS" in rendered + # The trade-off must be stated where a reader of the output will see it. + assert "captureOwnerStack" in rendered diff --git a/tests/units/components/base/test_script.py b/tests/units/components/base/test_script.py index 0f25b8d862d..0af582d4c32 100644 --- a/tests/units/components/base/test_script.py +++ b/tests/units/components/base/test_script.py @@ -26,3 +26,16 @@ def test_script_neither(): """Specifying neither children nor src is a ValueError.""" with pytest.raises(ValueError): Script.create() + + +def test_script_helmet_flushes_synchronously(): + """The Helmet wrapper must not defer head updates. + + react-helmet's default rAF-batched flush can be lost around hydration, + leaving the script tags out of the document entirely (flaky + "scripts not loaded" in test_call_script). + """ + component = Script.create("let x = 42") + render_dict = component.render() + assert render_dict["name"] == "Helmet" + assert "defer:false" in render_dict["props"]