Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6905.bugfix.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions news/6905.performance.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6905.performance.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 34 additions & 2 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions packages/reflex-components-core/news/6905.bugfix.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Helmet component module."""

from reflex_base.components.component import Component
from reflex_base.vars.base import Var


class Helmet(Component):
Expand All @@ -10,5 +11,8 @@ class Helmet(Component):

tag = "Helmet"

# Whether to batch client-side head updates via requestAnimationFrame.
defer: Var[bool]


helmet = Helmet.create
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)
)

Expand Down
38 changes: 38 additions & 0 deletions tests/units/compiler/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions tests/units/components/base/test_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading