From dbcefc2e55ea2d05747be295c8d089ae76d3e7e6 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 6 Oct 2026 11:03:59 +0530 Subject: [PATCH 01/20] fix: reduce client state provider nesting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/reflex-base/news/+7429.bugfix.md | 1 + .../src/reflex_base/compiler/templates.py | 74 ++++++++++++------- tests/units/compiler/test_compiler.py | 37 +++++++--- 3 files changed, 74 insertions(+), 38 deletions(-) create mode 100644 packages/reflex-base/news/+7429.bugfix.md diff --git a/packages/reflex-base/news/+7429.bugfix.md b/packages/reflex-base/news/+7429.bugfix.md new file mode 100644 index 00000000000..8e977fe52c9 --- /dev/null +++ b/packages/reflex-base/news/+7429.bugfix.md @@ -0,0 +1 @@ +Reduce client render-tree depth for apps with many state subclasses to prevent React mutation traversal stack overflows and blank pages. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index fe9ee5b0922..d362ed8ed81 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -541,7 +541,7 @@ def context_template( const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect; -// Holds the mutable substate -> dispatch registry that ``SubstateProvider`` +// Holds the mutable substate -> dispatch registry that ``ClientStateProvider`` // writes into and ``EventLoopProvider`` reads. The registry object identity is // stable for the lifetime of the tree, so neither adding a dispatcher nor // updating a substate re-renders the consumers of ``DispatchContext``. @@ -554,49 +554,67 @@ def context_template( ); }}; -// One provider per substate: each owns its own reducer, so a delta for one -// substate only re-renders its provider instead of recreating every provider. -const SubstateProvider = ({{ children, substateName, contextName }}) => {{ +// ``[substateName, contextName]`` for every substate, outermost first. +const SUBSTATES = [{substates_str} +]; + +function ClientStateProvider({{ children }}) {{ const dispatchers = useContext(DispatchContext); - const [state, dispatchSubstate] = useReducer( - applyDelta, - initialState[substateName], + const [state, dispatchState] = useReducer( + (state, action) => ({{ + ...state, + [action.substateName]: applyDelta( + state[action.substateName], + action.delta, + ), + }}), + initialState, ); + // A layout effect, not a passive one: layout effects for the whole commit // run before any passive effect, so every dispatcher is registered before // ``EventLoopProvider`` (mounted below this provider) connects the socket. // A delta naming an unregistered substate is a fatal state mismatch. useIsomorphicLayoutEffect(() => {{ - dispatchers[substateName] = dispatchSubstate; + const registeredSubstates = []; + for (const [substateName] of SUBSTATES) {{ + const dispatchSubstate = (delta) => + dispatchState({{ substateName, delta }}); + dispatchers[substateName] = dispatchSubstate; + registeredSubstates.push(substateName); + }} return () => {{ - delete dispatchers[substateName]; + for (const substateName of registeredSubstates) {{ + delete dispatchers[substateName]; + }} }}; - }}, [dispatchers, dispatchSubstate, substateName]); - return useMemo( - () => createElement(StateContexts[contextName], {{ value: state }}, children), - [children, state, contextName], - ); -}}; - -// ``[substateName, contextName]`` for every substate, outermost first. -const SUBSTATES = [{substates_str} -]; + }}, [dispatchers, dispatchState]); -function ClientStateProvider({{ children }}) {{ return useMemo(() => {{ let tree = children; for (let i = SUBSTATES.length - 1; i >= 0; i--) {{ const [substateName, contextName] = SUBSTATES[i]; - tree = createElement(SubstateProvider, {{ substateName, contextName }}, tree); + tree = createElement( + StateContexts[contextName], + {{ value: state[substateName] }}, + tree, + ); }} - return createElement(DispatchProvider, {{}}, tree); - }}, [children]); + return tree; + }}, [children, state]); +}} + +function BrowserStateProvider({{ children }}) {{ + return createElement( + DispatchProvider, + {{}}, + createElement(ClientStateProvider, {{}}, children), + ); }} // The server renders once and never applies a delta, so it provides the -// initial state through bare context providers. ``SubstateProvider`` would add -// a second render level per substate, and the server renderer recurses once per -// level, so with many substates rendering a page can exhaust the stack. +// initial state through bare context providers. The client keeps its reducer +// in one component, while the server renderer only needs the contexts. function ServerStateProvider({{ children }}) {{ let tree = children; for (let i = SUBSTATES.length - 1; i >= 0; i--) {{ @@ -611,7 +629,9 @@ def context_template( }} export const StateProvider = - typeof document === "undefined" ? ServerStateProvider : ClientStateProvider;""" + typeof document === "undefined" + ? ServerStateProvider + : BrowserStateProvider;""" def component_template(component: Component): diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index c3c1f9a3901..7825b8d516c 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1754,11 +1754,10 @@ def _render_two_substate_context() -> str: def test_context_template_one_provider_per_substate(): - """Each substate gets its own provider so one delta re-renders one context. + """Each substate gets its own context so one delta re-renders one context. - A single provider owning every reducer means any delta recreates every - ``StateContexts`` element; nesting one ``SubstateProvider`` per substate - keeps the untouched providers memoized. + The shared reducer preserves object identity for untouched substates, so + their context consumers do not re-render. """ rendered = _render_two_substate_context() @@ -1768,17 +1767,31 @@ def test_context_template_one_provider_per_substate(): " ['reflex___state____state__sub', 'reflex___state____state__sub'],\n" "];" in rendered ) - # The reducers live in SubstateProvider; the client provider only composes. + # The client provider owns the reducer and only composes context providers. client = rendered[ rendered.index("function ClientStateProvider") : rendered.index( "function ServerStateProvider" ) ] - assert "useReducer" not in client - assert "createElement(SubstateProvider, { substateName, contextName }, tree)" in ( - client - ) - assert "createElement(DispatchProvider, {}, tree)" in client + assert "useReducer(" in client + assert "StateContexts[contextName]," in client + assert "createElement(DispatchProvider, {}, tree)" not in client + + +def test_context_template_client_state_provider_does_not_nest_substate_components(): + """The client tree has only one React element level per substate.""" + rendered = _render_two_substate_context() + + client = rendered[ + rendered.index("function ClientStateProvider") : rendered.index( + "function ServerStateProvider" + ) + ] + + assert "const SubstateProvider" not in rendered + assert client.count("useReducer(") == 1 + assert "dispatchers[substateName]" in client + assert "StateContexts[contextName]," in client def test_context_template_server_state_provider_is_flat(): @@ -1800,7 +1813,9 @@ def test_context_template_server_state_provider_is_flat(): ) assert rendered.rstrip().endswith( "export const StateProvider =\n" - ' typeof document === "undefined" ? ServerStateProvider : ClientStateProvider;' + ' typeof document === "undefined"\n' + " ? ServerStateProvider\n" + " : BrowserStateProvider;" ) From 422610fe67ed5c7ef78d4ba4e66b65c552470c88 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 6 Oct 2026 11:33:39 +0530 Subject: [PATCH 02/20] fix: avoid copying all client substates per delta Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/reflex_base/compiler/templates.py | 25 ++-- tests/units/compiler/test_compiler.py | 2 + .../compiler/test_context_js_template.py | 124 ++++++++++++++++++ 3 files changed, 138 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index d362ed8ed81..6b0a782d01d 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -560,16 +560,15 @@ def context_template( function ClientStateProvider({{ children }}) {{ const dispatchers = useContext(DispatchContext); - const [state, dispatchState] = useReducer( - (state, action) => ({{ - ...state, - [action.substateName]: applyDelta( - state[action.substateName], - action.delta, - ), - }}), - initialState, - ); + const state = useRef({{ ...initialState }}); + const [version, render] = useReducer((version) => version + 1, 0); + const dispatchState = ({{ substateName, delta }}) => {{ + state.current[substateName] = applyDelta( + state.current[substateName], + delta, + ); + render(); + }}; // A layout effect, not a passive one: layout effects for the whole commit // run before any passive effect, so every dispatcher is registered before @@ -588,7 +587,7 @@ def context_template( delete dispatchers[substateName]; }} }}; - }}, [dispatchers, dispatchState]); + }}, [dispatchers]); return useMemo(() => {{ let tree = children; @@ -596,12 +595,12 @@ def context_template( const [substateName, contextName] = SUBSTATES[i]; tree = createElement( StateContexts[contextName], - {{ value: state[substateName] }}, + {{ value: state.current[substateName] }}, tree, ); }} return tree; - }}, [children, state]); + }}, [children, version]); }} function BrowserStateProvider({{ children }}) {{ diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 7825b8d516c..18333b204fb 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1774,6 +1774,8 @@ def test_context_template_one_provider_per_substate(): ) ] assert "useReducer(" in client + assert "state.current[substateName] = applyDelta(" in client + assert "...state," not in client assert "StateContexts[contextName]," in client assert "createElement(DispatchProvider, {}, tree)" not in client diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 16c181bc0a6..b4e9204edb8 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -168,3 +168,127 @@ def test_server_state_provider_renders_one_context_per_substate(tmp_path: Path): assert levels == [{"context": "DispatchContext"}] + [ {"context": name, "value": True} for name in names ] + + +@requires_node +def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): + """A client delta updates its substate without changing another context.""" + rendered = context_template( + is_dev_mode=True, + default_color_mode='"light"', + initial_state={ + "reflex___state____state": {"value": 1}, + "reflex___state____state__sub": {"value": 2}, + }, + state_name="reflex___state____state", + ).replace("function ClientStateProvider", "export function ClientStateProvider") + react_stub = """ +let hookIndex = 0; +let hookStates = []; +export let rerender; +export const dispatchers = {}; +export const useContext = () => dispatchers; +export const useMemo = (fn) => fn(); +export const useRef = (value) => { + const index = hookIndex++; + return hookStates[index] ??= { current: value }; +}; +export const useReducer = (reducer, initial) => { + const index = hookIndex++; + hookStates[index] ??= initial; + const dispatch = (action) => { + hookStates[index] = reducer(hookStates[index], action); + rerender(); + }; + return [hookStates[index], dispatch]; +}; +export const useState = (initial) => useReducer((_, value) => value, initial); +export const useEffect = () => {}; +export const useLayoutEffect = (effect) => effect(); +export const createElement = (type, props, ...children) => ({ + type, + props: { ...props, children: children.length === 1 ? children[0] : children }, +}); +export const resetHooks = () => { hookIndex = 0; }; +export const setRerender = (fn) => { rerender = fn; }; +""" + state_stub = """ +export const applyDelta = (state, delta) => ({ ...state, ...delta }); +export const ReflexEvent = () => ({}); +export const hydrateClientStorage = () => ({}); +export const useEventLoop = () => []; +export const refs = {}; +""" + context_stub = """ +export const ColorModeContext = {}; +export const UploadFilesContext = {}; +export const DispatchContext = {}; +export const EventLoopContext = {}; +export const getStateContext = (name) => ({ name }); +export const registerApp = () => {}; +export const eventLoop = {}; +""" + for specifier, source in ( + ("react", react_stub), + ("$/utils/state", state_stub), + ("$/utils/context-registry", context_stub), + ("@emotion/react", "export const jsx = () => null;\n"), + ): + stub = tmp_path / (specifier.replace("/", "_").replace("$", "") + ".mjs") + stub.write_text(source) + rendered = rendered.replace(f'from "{specifier}"', f'from "./{stub.name}"') + module = tmp_path / "context.mjs" + module.write_text(rendered) + driver = tmp_path / "driver.mjs" + driver.write_text( + """ +import * as react from "./react.mjs"; +globalThis.document = {}; +const mod = await import("./context.mjs"); +const leaf = { leaf: true }; +let tree; +const render = () => { + react.resetHooks(); + tree = mod.ClientStateProvider({ children: leaf }); +}; +react.setRerender(render); +render(); +const contexts = {}; +let node = tree; +while (node !== leaf) { + const name = Object.keys(mod.StateContexts).find( + (key) => mod.StateContexts[key] === node.type, + ); + contexts[name] = node.props.value; + node = node.props.children; +} +const untouched = contexts.reflex___state____state; +react.dispatchers["reflex___state____state__sub"]({ value: 3 }); +const updated = {}; +node = tree; +while (node !== leaf) { + const name = Object.keys(mod.StateContexts).find( + (key) => mod.StateContexts[key] === node.type, + ); + updated[name] = node.props.value; + node = node.props.children; +} +process.stdout.write(JSON.stringify({ + updated: updated.reflex___state____state__sub, + untouched: updated.reflex___state____state, + untouchedIdentity: updated.reflex___state____state === untouched, +})); +""" + ) + + result = subprocess.run( + ["node", str(driver)], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "updated": {"value": 3}, + "untouched": {"value": 1}, + "untouchedIdentity": True, + } From ab5f02873ced5cf3d07aa57eae5992f02cac13a5 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 8 Oct 2026 12:03:26 +0530 Subject: [PATCH 03/20] fix: flatten large state provider trees --- packages/reflex-base/news/+7429.bugfix.md | 2 +- .../.templates/web/utils/context-registry.js | 42 ++-- .../src/reflex_base/compiler/templates.py | 136 ++++-------- .../reflex-base/src/reflex_base/vars/base.py | 10 +- tests/units/compiler/test_compiler.py | 87 -------- .../compiler/test_context_js_template.py | 196 ++++++++++-------- tests/units/compiler/test_memoize_plugin.py | 24 +-- tests/units/components/test_component.py | 8 +- tests/units/test_app.py | 4 +- 9 files changed, 205 insertions(+), 304 deletions(-) diff --git a/packages/reflex-base/news/+7429.bugfix.md b/packages/reflex-base/news/+7429.bugfix.md index 8e977fe52c9..b55047d5c81 100644 --- a/packages/reflex-base/news/+7429.bugfix.md +++ b/packages/reflex-base/news/+7429.bugfix.md @@ -1 +1 @@ -Reduce client render-tree depth for apps with many state subclasses to prevent React mutation traversal stack overflows and blank pages. +Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index bfd4e105f67..c17efef2512 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -4,7 +4,12 @@ // a module the compiler rewrites: Vite re-executes a rewritten module on hot // update, and every module it imports along with it, but this module imports // nothing that can change and is never re-executed. -import { createContext } from "react"; +import { + createContext, + useCallback, + useContext, + useSyncExternalStore, +} from "react"; export const ColorModeContext = createContext({ rawColorMode: "system", @@ -15,31 +20,38 @@ export const ColorModeContext = createContext({ export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const EventLoopContext = createContext(null); +export const StateStoreContext = createContext(null); ColorModeContext.displayName = "ColorModeContext"; UploadFilesContext.displayName = "UploadFilesContext"; DispatchContext.displayName = "DispatchContext"; EventLoopContext.displayName = "EventLoopContext"; - -const stateContexts = new Map(); +StateStoreContext.displayName = "StateStoreContext"; /** - * Return the context object carrying the named Python state. + * Return the shared context carrying the Python state store. * - * The first call for a name creates the context; later calls, including ones - * made from a re-executed generated module, return the same object. + * @param {string} name - The dotted Python state name. + * @returns {React.Context} The shared state-store context. + */ +export function getStateContext(_name) { + return StateStoreContext; +} + +/** + * Subscribe a component to one Python substate in the shared store. * * @param {string} name - The dotted Python state name. - * @returns {React.Context} The state's context object. + * @returns {object} The current state value for the named substate. */ -export function getStateContext(name) { - let context = stateContexts.get(name); - if (context === undefined) { - context = createContext(null); - context.displayName = `StateContext(${name})`; - stateContexts.set(name, context); - } - return context; +export function useStateContext(name) { + const store = useContext(StateStoreContext); + const subscribe = useCallback( + (listener) => store.subscribe(name, listener), + [store, name], + ); + const getSnapshot = useCallback(() => store.getSnapshot(name), [store, name]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } // Values the generated context module registers each time it executes. The diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 6b0a782d01d..fe1582388ba 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -411,11 +411,6 @@ def context_template( """ ) - substates_str = "".join( - f"\n ['{state_name}', '{format_state_name(state_name)}']," - for state_name in initial_state - ) - disable_owner_stacks_str = ( r""" // Disable React dev-build owner-stack capture: the per-element Error() @@ -444,12 +439,12 @@ def context_template( else "" ) - return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useReducer, useRef, useState, createElement, useEffect, useLayoutEffect }} from "react" + return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react" import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" -import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, getStateContext, registerApp, eventLoop }} from "$/utils/context-registry" +import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, getStateContext, registerApp, eventLoop }} from "$/utils/context-registry" import {{ jsx }} from "@emotion/react"; {disable_owner_stacks_str} -export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext }}; +export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }}; export const initialState = {initial_state_json} export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)} @@ -536,101 +531,62 @@ def context_template( ); }} -// ``useLayoutEffect`` warns when rendered on the server, where no effect runs -// at all, so fall back to ``useEffect`` there. -const useIsomorphicLayoutEffect = - typeof document !== "undefined" ? useLayoutEffect : useEffect; - -// Holds the mutable substate -> dispatch registry that ``ClientStateProvider`` -// writes into and ``EventLoopProvider`` reads. The registry object identity is -// stable for the lifetime of the tree, so neither adding a dispatcher nor -// updating a substate re-renders the consumers of ``DispatchContext``. -const DispatchProvider = ({{ children }}) => {{ - const dispatchers = useRef({{}}); - return useMemo( - () => - createElement(DispatchContext, {{ value: dispatchers.current }}, children), - [children], +// Dispatch updates happen outside render; useSyncExternalStore keeps reads +// consistent while notifying only consumers of the changed substate. +function createStateStore() {{ + const state = {{ ...initialState }}; + const listeners = new Map( + Object.keys(state).map((substateName) => [substateName, new Set()]), + ); + const dispatchers = Object.fromEntries( + Object.keys(state).map((substateName) => [ + substateName, + (delta) => {{ + const nextState = applyDelta(state[substateName], delta); + if (Object.is(nextState, state[substateName])) return; + state[substateName] = nextState; + listeners.get(substateName)?.forEach((listener) => listener()); + }}, + ]), ); -}}; - -// ``[substateName, contextName]`` for every substate, outermost first. -const SUBSTATES = [{substates_str} -]; - -function ClientStateProvider({{ children }}) {{ - const dispatchers = useContext(DispatchContext); - const state = useRef({{ ...initialState }}); - const [version, render] = useReducer((version) => version + 1, 0); - const dispatchState = ({{ substateName, delta }}) => {{ - state.current[substateName] = applyDelta( - state.current[substateName], - delta, - ); - render(); - }}; - // A layout effect, not a passive one: layout effects for the whole commit - // run before any passive effect, so every dispatcher is registered before - // ``EventLoopProvider`` (mounted below this provider) connects the socket. - // A delta naming an unregistered substate is a fatal state mismatch. - useIsomorphicLayoutEffect(() => {{ - const registeredSubstates = []; - for (const [substateName] of SUBSTATES) {{ - const dispatchSubstate = (delta) => - dispatchState({{ substateName, delta }}); - dispatchers[substateName] = dispatchSubstate; - registeredSubstates.push(substateName); - }} - return () => {{ - for (const substateName of registeredSubstates) {{ - delete dispatchers[substateName]; + return {{ + dispatchers, + getSnapshot: (substateName) => state[substateName], + subscribe: (substateName, listener) => {{ + let substateListeners = listeners.get(substateName); + if (!substateListeners) {{ + substateListeners = new Set(); + listeners.set(substateName, substateListeners); }} - }}; - }}, [dispatchers]); - - return useMemo(() => {{ - let tree = children; - for (let i = SUBSTATES.length - 1; i >= 0; i--) {{ - const [substateName, contextName] = SUBSTATES[i]; - tree = createElement( - StateContexts[contextName], - {{ value: state.current[substateName] }}, - tree, - ); - }} - return tree; - }}, [children, version]); + substateListeners.add(listener); + return () => substateListeners.delete(listener); + }}, + }}; }} -function BrowserStateProvider({{ children }}) {{ +function StateStoreProviders({{ children, store }}) {{ return createElement( - DispatchProvider, - {{}}, - createElement(ClientStateProvider, {{}}, children), + StateStoreContext, + {{ value: store }}, + createElement(DispatchContext, {{ value: store.dispatchers }}, children), ); }} -// The server renders once and never applies a delta, so it provides the -// initial state through bare context providers. The client keeps its reducer -// in one component, while the server renderer only needs the contexts. +function ClientStateProvider({{ children }}) {{ + const [store] = useState(createStateStore); + return createElement(StateStoreProviders, {{ children, store }}); +}} + function ServerStateProvider({{ children }}) {{ - let tree = children; - for (let i = SUBSTATES.length - 1; i >= 0; i--) {{ - const [substateName, contextName] = SUBSTATES[i]; - tree = createElement( - StateContexts[contextName], - {{ value: initialState[substateName] }}, - tree, - ); - }} - return createElement(DispatchContext, {{ value: {{}} }}, tree); + return createElement( + StateStoreProviders, + {{ children, store: createStateStore() }}, + ); }} export const StateProvider = - typeof document === "undefined" - ? ServerStateProvider - : BrowserStateProvider;""" + typeof document === "undefined" ? ServerStateProvider : ClientStateProvider;""" def component_template(component: Component): diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 4c10857bcc9..48cdd641b5e 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -630,19 +630,17 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa from reflex_base.utils import format state_name = state if isinstance(state, str) else state.get_full_name() + state_var_name = format.format_state_name(state_name) return VarData( state=state_name, field_name=field_name, hooks={ - "const {0} = useContext(StateContexts.{0})".format( - format.format_state_name(state_name) - ): None + f"const {state_var_name} = useStateContext({json.dumps(state_name)})": None }, imports={ - f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")], - "react": [ImportVar(tag="useContext")], + "$/utils/context-registry": [ImportVar(tag="useStateContext")], }, - # State Vars read ``StateContexts``/``EventLoopContext``, so the + # State Vars read the shared store/``EventLoopContext``, so the # providers must enclose every component that uses them. app_wraps=get_event_app_wraps(), ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 18333b204fb..5036a7517f7 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1734,93 +1734,6 @@ def test_context_template_owner_stack_pin(disable_owner_stacks: bool): assert "captureOwnerStack" in rendered -def _render_two_substate_context() -> str: - """Render the context template for a state with one substate. - - Returns: - The rendered context module source. - """ - from reflex_base.compiler.templates import context_template - - return context_template( - is_dev_mode=True, - default_color_mode='"light"', - initial_state={ - "reflex___state____state": {}, - "reflex___state____state__sub": {}, - }, - state_name="reflex___state____state", - ) - - -def test_context_template_one_provider_per_substate(): - """Each substate gets its own context so one delta re-renders one context. - - The shared reducer preserves object identity for untouched substates, so - their context consumers do not re-render. - """ - rendered = _render_two_substate_context() - - assert ( - "const SUBSTATES = [\n" - " ['reflex___state____state', 'reflex___state____state'],\n" - " ['reflex___state____state__sub', 'reflex___state____state__sub'],\n" - "];" in rendered - ) - # The client provider owns the reducer and only composes context providers. - client = rendered[ - rendered.index("function ClientStateProvider") : rendered.index( - "function ServerStateProvider" - ) - ] - assert "useReducer(" in client - assert "state.current[substateName] = applyDelta(" in client - assert "...state," not in client - assert "StateContexts[contextName]," in client - assert "createElement(DispatchProvider, {}, tree)" not in client - - -def test_context_template_client_state_provider_does_not_nest_substate_components(): - """The client tree has only one React element level per substate.""" - rendered = _render_two_substate_context() - - client = rendered[ - rendered.index("function ClientStateProvider") : rendered.index( - "function ServerStateProvider" - ) - ] - - assert "const SubstateProvider" not in rendered - assert client.count("useReducer(") == 1 - assert "dispatchers[substateName]" in client - assert "StateContexts[contextName]," in client - - -def test_context_template_server_state_provider_is_flat(): - """The server provides initial state without a component per substate. - - Server rendering recurses once per element level, so a ``SubstateProvider`` - around every context doubled the depth of every page render and overflowed - the stack of apps with many substates. - """ - rendered = _render_two_substate_context() - - server = rendered[rendered.index("function ServerStateProvider") :] - assert "SubstateProvider" not in server - assert "DispatchProvider" not in server - assert "useReducer" not in server - assert ( - "StateContexts[contextName],\n" - " { value: initialState[substateName] }," in server - ) - assert rendered.rstrip().endswith( - "export const StateProvider =\n" - ' typeof document === "undefined"\n' - " ? ServerStateProvider\n" - " : BrowserStateProvider;" - ) - - def test_context_template_client_side_component_is_named(): """``ClientSide`` returns a named component, not an anonymous arrow.""" from reflex_base.compiler.templates import context_template diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index b4e9204edb8..c23764b241e 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -12,14 +12,15 @@ requires_node = pytest.mark.skipif(shutil.which("node") is None, reason="node missing") # Stand-ins for the modules context.js imports, keyed by the specifier they -# replace. ``createElement`` returns a plain tree to walk, and every hook throws: -# the server provider must not need React's dispatcher at all. +# replace. Server tests fail if a client-only hook runs without a dispatcher. _STUB_MODULES = { "react": """ const hook = (name) => () => { throw new Error(`${name} called while rendering on the server`); }; export const useContext = hook("useContext"); +export const useCallback = hook("useCallback"); +export const useSyncExternalStore = hook("useSyncExternalStore"); export const useMemo = hook("useMemo"); export const useReducer = hook("useReducer"); export const useRef = hook("useRef"); @@ -28,8 +29,18 @@ export const useLayoutEffect = hook("useLayoutEffect"); export const createElement = (type, props, ...children) => ({ type, - props: { ...props, children: children.length === 1 ? children[0] : children }, + props: { + ...props, + children: children.length + ? children.length === 1 ? children[0] : children + : props?.children, + }, }); +export const createContext = (value) => { + const context = { defaultValue: value }; + context.Provider = context; + return context; +}; """, "$/utils/state": """ export const applyDelta = () => {}; @@ -43,16 +54,17 @@ export const UploadFilesContext = {}; export const DispatchContext = {}; export const EventLoopContext = {}; +export const StateStoreContext = { name: "StateStoreContext" }; +StateStoreContext.Provider = StateStoreContext; export const getStateContext = () => ({}); +export const useStateContext = () => ({}); export const registerApp = () => {}; export const eventLoop = {}; """, "@emotion/react": "export const jsx = () => null;\n", } -# Renders ``StateProvider`` around a leaf and reports every level between them, -# outermost first: a component by name, or a context with whether its value is -# the one the server should provide. +# Renders ``StateProvider`` around a leaf and resolves its fixed-depth wrapper. _SERVER_RENDER_DRIVER = """ import { pathToFileURL } from "node:url"; const mod = await import(pathToFileURL(process.argv[2]).href); @@ -62,17 +74,21 @@ while (node !== leaf) { if (typeof node.type === "function") { levels.push({ component: node.type.name }); - break; - } - if (node.type === mod.DispatchContext) { + node = node.type(node.props); + } else if (node.type === mod.DispatchContext) { levels.push({ context: "DispatchContext" }); + node = node.props.children; + } else if (node.type === mod.StateStoreContext) { + const store = node.props.value; + levels.push({ + context: "StateStoreContext", + first: store.getSnapshot(process.argv[3]), + last: store.getSnapshot(process.argv[4]), + }); + node = node.props.children; } else { - const name = Object.keys(mod.StateContexts).find( - (key) => mod.StateContexts[key] === node.type, - ); - levels.push({ context: name, value: node.props.value === mod.initialState[name] }); + throw new Error("unexpected provider in state tree"); } - node = node.props.children; } process.stdout.write(JSON.stringify(levels)); """ @@ -91,7 +107,7 @@ def _event_loop_provider_body() -> str: state_name="state", ) start = rendered.index("export function EventLoopProvider") - end = rendered.index("const useIsomorphicLayoutEffect", start) + end = rendered.index("function createStateStore", start) return rendered[start:end] @@ -132,15 +148,10 @@ def test_event_loop_provider_still_publishes_module_dispatchers() -> None: @requires_node -def test_server_state_provider_renders_one_context_per_substate(tmp_path: Path): - """On the server, each substate adds exactly one level to the page render. - - React's server renderer recurses once per element level, so wrapping every - substate's context in its own component doubled the depth of every page and - overflowed the stack of apps with many substates. - """ +def test_server_state_provider_depth_is_constant_for_many_substates(tmp_path: Path): + """The server provider tree stays shallow as the state tree grows.""" names = ["reflex___state____state"] + [ - f"reflex___state____state__sub_{i}" for i in range(199) + f"reflex___state____state__sub_{i}" for i in range(4999) ] rendered = context_template( is_dev_mode=True, @@ -158,21 +169,27 @@ def test_server_state_provider_renders_one_context_per_substate(tmp_path: Path): driver.write_text(_SERVER_RENDER_DRIVER) result = subprocess.run( - ["node", str(driver), str(module)], + ["node", str(driver), str(module), names[0], names[-1]], capture_output=True, encoding="utf-8", ) assert result.returncode == 0, result.stderr levels = json.loads(result.stdout) - assert levels == [{"context": "DispatchContext"}] + [ - {"context": name, "value": True} for name in names + assert levels == [ + {"component": "StateStoreProviders"}, + { + "context": "StateStoreContext", + "first": {"n": 0}, + "last": {"n": len(names) - 1}, + }, + {"context": "DispatchContext"}, ] @requires_node -def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): - """A client delta updates its substate without changing another context.""" +def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): + """A client delta updates and notifies only the changed substate.""" rendered = context_template( is_dev_mode=True, default_color_mode='"light"', @@ -185,32 +202,36 @@ def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): react_stub = """ let hookIndex = 0; let hookStates = []; -export let rerender; -export const dispatchers = {}; -export const useContext = () => dispatchers; -export const useMemo = (fn) => fn(); -export const useRef = (value) => { - const index = hookIndex++; - return hookStates[index] ??= { current: value }; +const contextValues = new Map(); +export const subscriptions = []; +export const useContext = (context) => contextValues.get(context); +export const useCallback = (fn) => fn; +export const useSyncExternalStore = (subscribe, getSnapshot) => { + subscriptions.push(subscribe); + return getSnapshot(); }; -export const useReducer = (reducer, initial) => { +export const useMemo = (fn) => fn(); +export const useState = (initial) => { const index = hookIndex++; - hookStates[index] ??= initial; - const dispatch = (action) => { - hookStates[index] = reducer(hookStates[index], action); - rerender(); - }; - return [hookStates[index], dispatch]; + hookStates[index] ??= typeof initial === "function" ? initial() : initial; + return [hookStates[index], () => {}]; }; -export const useState = (initial) => useReducer((_, value) => value, initial); export const useEffect = () => {}; -export const useLayoutEffect = (effect) => effect(); +export const createContext = (defaultValue) => { + const context = { defaultValue }; + context.Provider = context; + return context; +}; export const createElement = (type, props, ...children) => ({ type, - props: { ...props, children: children.length === 1 ? children[0] : children }, + props: { + ...props, + children: children.length + ? children.length === 1 ? children[0] : children + : props?.children, + }, }); -export const resetHooks = () => { hookIndex = 0; }; -export const setRerender = (fn) => { rerender = fn; }; +export const setContextValue = (context, value) => contextValues.set(context, value); """ state_stub = """ export const applyDelta = (state, delta) => ({ ...state, ...delta }); @@ -219,22 +240,24 @@ def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): export const useEventLoop = () => []; export const refs = {}; """ - context_stub = """ -export const ColorModeContext = {}; -export const UploadFilesContext = {}; -export const DispatchContext = {}; -export const EventLoopContext = {}; -export const getStateContext = (name) => ({ name }); -export const registerApp = () => {}; -export const eventLoop = {}; -""" + registry_path = ( + Path(__file__).parents[3] + / "packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js" + ) + registry_source = registry_path.read_text().replace( + 'from "react"', 'from "./react.mjs"' + ) for specifier, source in ( ("react", react_stub), ("$/utils/state", state_stub), - ("$/utils/context-registry", context_stub), + ("$/utils/context-registry", registry_source), ("@emotion/react", "export const jsx = () => null;\n"), ): - stub = tmp_path / (specifier.replace("/", "_").replace("$", "") + ".mjs") + stub = tmp_path / ( + "registry.mjs" + if specifier == "$/utils/context-registry" + else specifier.replace("/", "_").replace("$", "") + ".mjs" + ) stub.write_text(source) rendered = rendered.replace(f'from "{specifier}"', f'from "./{stub.name}"') module = tmp_path / "context.mjs" @@ -243,40 +266,30 @@ def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): driver.write_text( """ import * as react from "./react.mjs"; +import * as registry from "./registry.mjs"; globalThis.document = {}; const mod = await import("./context.mjs"); const leaf = { leaf: true }; -let tree; -const render = () => { - react.resetHooks(); - tree = mod.ClientStateProvider({ children: leaf }); -}; -react.setRerender(render); -render(); -const contexts = {}; -let node = tree; -while (node !== leaf) { - const name = Object.keys(mod.StateContexts).find( - (key) => mod.StateContexts[key] === node.type, - ); - contexts[name] = node.props.value; - node = node.props.children; -} -const untouched = contexts.reflex___state____state; -react.dispatchers["reflex___state____state__sub"]({ value: 3 }); -const updated = {}; -node = tree; -while (node !== leaf) { - const name = Object.keys(mod.StateContexts).find( - (key) => mod.StateContexts[key] === node.type, - ); - updated[name] = node.props.value; - node = node.props.children; -} +let node = mod.ClientStateProvider({ children: leaf }); +node = node.type(node.props); +const storeElement = node; +const store = storeElement.props.value; +react.setContextValue(registry.StateStoreContext, store); +const first = registry.useStateContext("reflex___state____state"); +const second = registry.useStateContext("reflex___state____state__sub"); +let firstNotifications = 0; +let secondNotifications = 0; +react.subscriptions[0](() => firstNotifications++); +react.subscriptions[1](() => secondNotifications++); +store.dispatchers["reflex___state____state__sub"]({ value: 3 }); +const updated = registry.useStateContext("reflex___state____state__sub"); process.stdout.write(JSON.stringify({ - updated: updated.reflex___state____state__sub, - untouched: updated.reflex___state____state, - untouchedIdentity: updated.reflex___state____state === untouched, + first, + second, + updated, + firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, + firstNotifications, + secondNotifications, })); """ ) @@ -288,7 +301,10 @@ def test_client_state_provider_routes_delta_to_one_substate(tmp_path: Path): ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { + "first": {"value": 1}, + "second": {"value": 2}, "updated": {"value": 3}, - "untouched": {"value": 1}, - "untouchedIdentity": True, + "firstIdentityPreserved": True, + "firstNotifications": 0, + "secondNotifications": 1, } diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 1d70fda1a51..c9a475ef5f2 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -353,7 +353,7 @@ def test_special_form_memo_wrappers_render_structural_body( """Generated memo wrappers for special forms render the structural body. The memo body must subscribe to the state the special form references - (via ``useContext(StateContexts...)``), and the page must not — otherwise + (via ``useStateContext(...)``), and the page must not — otherwise the state-dependent render has leaked into page scope. """ from reflex.compiler.compiler import compile_memo_components @@ -389,7 +389,7 @@ def special_child() -> Component: ) memo_code = "\n".join(code for _, code in memo_files) - state_wiring = "useContext(StateContexts" + state_wiring = "useStateContext(" page_output = page_ctx.output_code assert page_output is not None assert state_wiring in memo_code @@ -606,8 +606,8 @@ def stateful_card(label: rx.Var[str]) -> Component: page_output = page_ctx.output_code assert page_output is not None - assert "useContext(StateContexts" not in page_output - assert not any("useContext(StateContexts" in hook for hook in page_ctx.hooks) + assert "useStateContext(" not in page_output + assert not any("useStateContext(" in hook for hook in page_ctx.hooks) (definition,) = ctx.auto_memo_components.values() assert isinstance(definition, MemoComponentDefinition) @@ -625,7 +625,7 @@ def stateful_card(label: rx.Var[str]) -> Component: wrapper_code = next( code for path, code in memo_files if definition.export_name in path ) - assert "useContext(StateContexts" in wrapper_code + assert "useStateContext(" in wrapper_code assert f"jsx({wrapped.tag}," in wrapper_code @@ -700,7 +700,7 @@ def slot_card(label: rx.Var[str], children: rx.Var[Component]) -> Component: page_output = page_ctx.output_code assert page_output is not None - assert "useContext(StateContexts" not in page_output + assert "useStateContext(" not in page_output assert 'jsx(Plain,{},"static child")' in page_output wrapper_definition = next( @@ -2049,7 +2049,7 @@ def test_accordion_trigger_with_stateful_cond_is_memoized() -> None: output = page_ctx.output_code assert output is not None - assert "useContext(StateContexts" not in output, ( + assert "useStateContext(" not in output, ( "State read leaked into the page module — the trigger's stateful cond " "should be captured inside the snapshot wrapper instead.\n" f"Page output snippet: {output[:2000]}" @@ -2192,7 +2192,7 @@ def test_snapshot_boundary_memo_body_subscribes_state_in_body_not_page() -> None """State subscription wiring lives in the memo body, not in the page module. The whole point of memoization is to isolate state reads from the page. - This asserts that ``useContext(StateContexts…)`` (state subscription) + This asserts that ``useStateContext(...)`` (state subscription) appears in the memo module and NOT in the page output, confirming the state read landed inside the snapshot wrapper. """ @@ -2208,12 +2208,12 @@ def test_snapshot_boundary_memo_body_subscribes_state_in_body_not_page() -> None ctx, page_ctx = _compile_single_page(lambda: trigger) memo_code = _compile_memo_module_text(ctx) - assert "useContext(StateContexts" in memo_code, ( + assert "useStateContext(" in memo_code, ( "Snapshot wrapper should subscribe to state inside the memo body." ) page_output = page_ctx.output_code assert page_output is not None - assert "useContext(StateContexts" not in page_output, ( + assert "useStateContext(" not in page_output, ( "State subscription should NOT appear in the page module — it must be " "isolated inside the snapshot wrapper.\n" f"Page output:\n{page_output[:2000]}" @@ -2385,7 +2385,7 @@ def test_void_element_with_only_stateful_prop_memoizes_via_snapshot() -> None: assert len(ctx.memoize_wrappers) == 1 output = page_ctx.output_code assert output is not None - assert "useContext(StateContexts" not in output + assert "useStateContext(" not in output @pytest.mark.parametrize( @@ -2618,7 +2618,7 @@ def test_real_recursive_false_components_with_stateful_descendants_snapshot_wrap ) page_output = page_ctx.output_code assert page_output is not None - assert "useContext(StateContexts" not in page_output, ( + assert "useStateContext(" not in page_output, ( "Stateful descendant under a real snapshot boundary leaked to page output.\n" f"Page output snippet: {page_output[:2000]}" ) diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index a54ca2b80c5..023a3234484 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -2141,9 +2141,15 @@ def add_style(self): # pyright: ignore [reportIncompatibleMethodOverride] page._add_style_recursive(Style()) assert ( - f"const {test_state.get_name()} = useContext(StateContexts.{test_state.get_name()})" + f'const {test_state.get_name()} = useStateContext("{test_state.get_full_name()}")' in page._get_all_hooks_internal() ) + assert any( + import_var.tag == "useStateContext" + for import_var in dict(VarData.from_state(test_state).imports)[ + "$/utils/context-registry" + ] + ) assert "useText" in page._get_all_hooks_internal() assert "useParent" in page._get_all_hooks_internal() assert ( diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 85eaeca933b..d6d6bdfc9a9 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2995,7 +2995,7 @@ class AppWrapState(State): ).read_text() app_wrap_fn = root_contents[root_contents.index("function AppWrap") :] # No state hooks may remain in the AppWrap function body. - assert "useContext(StateContexts" not in app_wrap_fn + assert "useStateContext(" not in app_wrap_fn # The route children still flow through the (memoized) wrap chain, which # renders inside the state provider. assert "jsx(StateProvider" in app_wrap_fn @@ -3004,7 +3004,7 @@ class AppWrapState(State): # The extracted memo module carries the state hook instead. memo_dir = web_dir / constants.Dirs.COMPONENTS_PATH assert any( - "useContext(StateContexts" in memo_file.read_text() + "useStateContext(" in memo_file.read_text() for memo_file in memo_dir.glob(f"*{constants.Ext.JSX}") ) From ef50437b1ecf716060c943622c2717416622b2b5 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 8 Oct 2026 12:56:08 +0530 Subject: [PATCH 04/20] fix: address state provider review feedback --- .../src/reflex_base/compiler/templates.py | 11 +++++++++-- reflex/state.py | 7 +++++-- tests/units/compiler/test_context_js_template.py | 9 +++++++++ tests/units/components/test_component.py | 4 +--- tests/units/test_var.py | 14 +++++++++++++- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index fe1582388ba..96631eaaed7 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -542,8 +542,15 @@ def context_template( Object.keys(state).map((substateName) => [ substateName, (delta) => {{ - const nextState = applyDelta(state[substateName], delta); - if (Object.is(nextState, state[substateName])) return; + const currentState = state[substateName]; + if ( + Object.keys(delta).every((key) => + Object.is(currentState[key], delta[key]), + ) + ) {{ + return; + }} + const nextState = applyDelta(currentState, delta); state[substateName] = nextState; listeners.get(substateName)?.forEach((listener) => listener()); }}, diff --git a/reflex/state.py b/reflex/state.py index 82c1476707c..37ed575329b 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2841,9 +2841,12 @@ def code_uses_state_contexts(javascript_code: str) -> bool: javascript_code: The Javascript code to check. Returns: - True if the code attempts to access a member of StateContexts. + True if the code subscribes to a state context. """ - return bool("useContext(StateContexts" in javascript_code) + return bool( + "useStateContext(" in javascript_code + or "useContext(StateContexts" in javascript_code + ) def reload_state_module( diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index c23764b241e..e172515feb0 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -281,12 +281,19 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): let secondNotifications = 0; react.subscriptions[0](() => firstNotifications++); react.subscriptions[1](() => secondNotifications++); +store.dispatchers["reflex___state____state__sub"]({ value: 2 }); +store.dispatchers["reflex___state____state__sub"]({}); +const noOpIdentityPreserved = + store.getSnapshot("reflex___state____state__sub") === second; +const notificationsAfterNoOps = secondNotifications; store.dispatchers["reflex___state____state__sub"]({ value: 3 }); const updated = registry.useStateContext("reflex___state____state__sub"); process.stdout.write(JSON.stringify({ first, second, updated, + noOpIdentityPreserved, + notificationsAfterNoOps, firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, firstNotifications, secondNotifications, @@ -304,6 +311,8 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "first": {"value": 1}, "second": {"value": 2}, "updated": {"value": 3}, + "noOpIdentityPreserved": True, + "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, "firstNotifications": 0, "secondNotifications": 1, diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 023a3234484..6c16a875d16 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -2146,9 +2146,7 @@ def add_style(self): # pyright: ignore [reportIncompatibleMethodOverride] ) assert any( import_var.tag == "useStateContext" - for import_var in dict(VarData.from_state(test_state).imports)[ - "$/utils/context-registry" - ] + for import_var in page._get_all_imports()["$/utils/context-registry"] ) assert "useText" in page._get_all_hooks_internal() assert "useParent" in page._get_all_hooks_internal() diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 5cfbd33ea93..1ccb77abb4c 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -55,7 +55,7 @@ import reflex as rx from reflex.environment import PerformanceMode -from reflex.state import BaseState +from reflex.state import BaseState, code_uses_state_contexts pytest.importorskip("pydantic") @@ -1561,6 +1561,18 @@ def test_retrieval(): ) +@pytest.mark.parametrize( + ("javascript_code", "expected"), + [ + ("const state = useStateContext('state')", True), + ("const state = useContext(StateContexts.state)", True), + ("const state = useContext(ThemeContext)", False), + ], +) +def test_code_uses_state_contexts(javascript_code: str, expected: bool): + assert code_uses_state_contexts(javascript_code) is expected + + def test_fstring_concat(): original_var_with_data = LiteralVar.create( "imagination", _var_data=VarData(state="fear") From 8543148d973bf84fffccc690ea21e31fe1be7f69 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 8 Oct 2026 13:13:27 +0530 Subject: [PATCH 05/20] docs: add PR 7441 performance news fragment --- .../reflex-base/news/{+7429.bugfix.md => +7441.performance.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+7429.bugfix.md => +7441.performance.md} (100%) diff --git a/packages/reflex-base/news/+7429.bugfix.md b/packages/reflex-base/news/+7441.performance.md similarity index 100% rename from packages/reflex-base/news/+7429.bugfix.md rename to packages/reflex-base/news/+7441.performance.md From 8510ecaac9c64da4094c793f66f9094450c95ce8 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 8 Oct 2026 13:13:57 +0530 Subject: [PATCH 06/20] docs: add reflex package performance news fragment --- news/7441.performance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/7441.performance.md diff --git a/news/7441.performance.md b/news/7441.performance.md new file mode 100644 index 00000000000..b55047d5c81 --- /dev/null +++ b/news/7441.performance.md @@ -0,0 +1 @@ +Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses. From 6cd23f88660dfc79f1b0e52fc7731f5b815325a4 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 8 Oct 2026 13:40:18 +0530 Subject: [PATCH 07/20] docs: use valid orphan news fragment name --- ...1.performance.md => +large-state-provider-tree.performance.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+7441.performance.md => +large-state-provider-tree.performance.md} (100%) diff --git a/packages/reflex-base/news/+7441.performance.md b/packages/reflex-base/news/+large-state-provider-tree.performance.md similarity index 100% rename from packages/reflex-base/news/+7441.performance.md rename to packages/reflex-base/news/+large-state-provider-tree.performance.md From 4e9f74675bd4492b491982df3bddbe2bc68acb46 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 10 Oct 2026 13:17:12 +0530 Subject: [PATCH 08/20] fix: preserve legacy state context reads --- .../.templates/web/utils/context-registry.js | 15 +++-- .../src/reflex_base/compiler/templates.py | 28 ++++++++- reflex/compiler/compiler.py | 62 +++++++++++++++---- tests/units/compiler/test_compiler.py | 17 +++++ .../compiler/test_context_js_template.py | 15 +++++ 5 files changed, 119 insertions(+), 18 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index c17efef2512..44b3dbb7eb9 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -21,6 +21,7 @@ export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const EventLoopContext = createContext(null); export const StateStoreContext = createContext(null); +const stateContexts = new Map(); ColorModeContext.displayName = "ColorModeContext"; UploadFilesContext.displayName = "UploadFilesContext"; @@ -29,13 +30,19 @@ EventLoopContext.displayName = "EventLoopContext"; StateStoreContext.displayName = "StateStoreContext"; /** - * Return the shared context carrying the Python state store. + * Return the stable legacy context for a named Python state. * * @param {string} name - The dotted Python state name. - * @returns {React.Context} The shared state-store context. + * @returns {React.Context} The named state's context object. */ -export function getStateContext(_name) { - return StateStoreContext; +export function getStateContext(name) { + let context = stateContexts.get(name); + if (context === undefined) { + context = createContext(null); + context.displayName = `StateContext(${name})`; + stateContexts.set(name, context); + } + return context; } /** diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 96631eaaed7..51bd2dfbb96 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -315,6 +315,7 @@ def context_template( state_name: str | None = None, client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, disable_react_owner_stacks: bool = False, + legacy_state_contexts: set[str] | None = None, ): """Template for the context file. @@ -331,6 +332,8 @@ def context_template( 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). + legacy_state_contexts: Formatted state names read through the legacy + ``useContext(StateContexts.)`` API. Returns: Rendered context file content as string. @@ -344,6 +347,13 @@ def context_template( f'{format_state_name(state_name)}: getStateContext("{state_name}"),' for state_name in initial_state ]) + legacy_contexts_str = ",".join( + f"[{json.dumps(state_name)}, " + f"StateContexts[{json.dumps(format_state_name(state_name))}]]" + for state_name in initial_state + if "*" in (legacy_state_contexts or set()) + or format_state_name(state_name) in (legacy_state_contexts or set()) + ) state_str = ( rf""" @@ -441,7 +451,7 @@ def context_template( return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react" import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" -import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, getStateContext, registerApp, eventLoop }} from "$/utils/context-registry" +import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, getStateContext, useStateContext, registerApp, eventLoop }} from "$/utils/context-registry" import {{ jsx }} from "@emotion/react"; {disable_owner_stacks_str} export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }}; @@ -573,13 +583,27 @@ def context_template( }} function StateStoreProviders({{ children, store }}) {{ + let stateTree = children; + for (const [stateName, context] of LEGACY_STATE_CONTEXTS.slice().reverse()) {{ + stateTree = createElement( + LegacyStateContextProvider, + {{ children: stateTree, stateName, context }}, + ); + }} return createElement( StateStoreContext, {{ value: store }}, - createElement(DispatchContext, {{ value: store.dispatchers }}, children), + createElement(DispatchContext, {{ value: store.dispatchers }}, stateTree), ); }} +const LEGACY_STATE_CONTEXTS = [{legacy_contexts_str}]; + +function LegacyStateContextProvider({{ children, stateName, context }}) {{ + const state = useStateContext(stateName); + return createElement(context, {{ value: state }}, children); +}} + function ClientStateProvider({{ children }}) {{ const [store] = useState(createStateStore); return createElement(StateStoreProviders, {{ children, store }}); diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index e232cea987b..f866798d0c4 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -6,6 +6,7 @@ import dataclasses import json import logging +import re import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule @@ -246,6 +247,7 @@ def _compile_contexts( theme: Component | None, *, component_imports: ParsedImportDict | None = None, + legacy_state_contexts: set[str] | None = None, ) -> str: """Compile the initial state and contexts. @@ -253,6 +255,7 @@ def _compile_contexts( state: The app state. theme: The top-level app theme. component_imports: Optional accumulator for initial component dependencies. + legacy_state_contexts: State context names used through the legacy React API. Returns: The compiled context file. @@ -277,12 +280,14 @@ def _compile_contexts( is_dev_mode=not is_prod_mode(), default_color_mode=default_color_mode, disable_react_owner_stacks=disable_react_owner_stacks, + legacy_state_contexts=legacy_state_contexts, ) if state and initial_state is not None 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, + legacy_state_contexts=legacy_state_contexts, ) ) @@ -766,6 +771,7 @@ def compile_contexts( theme: Component | None, *, component_imports: ParsedImportDict | None = None, + legacy_state_contexts: set[str] | None = None, ) -> tuple[str, str]: """Compile the initial state / context. @@ -773,6 +779,7 @@ def compile_contexts( state: The app state. theme: The top-level app theme. component_imports: Optional accumulator for initial component dependencies. + legacy_state_contexts: State context names used through the legacy React API. Returns: The path and code of the compiled context. @@ -781,10 +788,35 @@ def compile_contexts( output_path = utils.get_context_path() return output_path, _compile_contexts( - state, theme, component_imports=component_imports + state, + theme, + component_imports=component_imports, + legacy_state_contexts=legacy_state_contexts, ) +def _legacy_state_context_names(javascript_code: str) -> set[str]: + """Find state contexts read through the pre-store React context API.""" + context_names = set( + re.findall( + r"\buseContext\s*\(\s*StateContexts\.([A-Za-z_$][\w$]*)\s*\)", + javascript_code, + ) + ) + context_names.update( + re.findall( + r"\buseContext\s*\(\s*StateContexts\[['\"]([^'\"]+)['\"]\]\s*\)", + javascript_code, + ) + ) + if re.search( + r"\buseContext\s*\(\s*StateContexts\s*\[\s*(?!['\"])", + javascript_code, + ): + context_names.add("*") + return context_names + + def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: """Compile a single page. @@ -1490,17 +1522,23 @@ def add_save_task( compile_results.append(result) progress.advance(task) - compile_results.extend([ - compile_contexts( - app._state, - radix_themes_plugin.get_theme(), - component_imports=all_imports, - ), - utils._compile_bundled_libraries(), - ]) - progress.advance(task) - - compile_results.append(compile_app_root(app_root, hydrate_fallback_export)) + app_root_result = compile_app_root(app_root, hydrate_fallback_export) + legacy_state_contexts = set().union( + *(_legacy_state_context_names(code) for _, code in compile_results) + ) + legacy_state_contexts.update(_legacy_state_context_names(app_root_result[1])) + compile_results.extend( + [ + compile_contexts( + app._state, + radix_themes_plugin.get_theme(), + component_imports=all_imports, + legacy_state_contexts=legacy_state_contexts, + ), + utils._compile_bundled_libraries(), + app_root_result, + ] + ) progress.advance(task) progress.stop() diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 5036a7517f7..f7ff92e5c11 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -32,6 +32,23 @@ from reflex.utils import prerequisites +@pytest.mark.parametrize( + ("javascript_code", "expected"), + [ + ("useContext(StateContexts.state)", {"state"}), + ( + 'useContext(StateContexts["state"]); useContext(StateContexts.other)', + {"state", "other"}, + ), + ("useContext(StateContexts[stateName])", {"*"}), + ('useStateContext("state")', set()), + ], +) +def test_legacy_state_context_names(javascript_code: str, expected: set[str]) -> None: + """Identify legacy context reads so generated providers can preserve them.""" + assert compiler._legacy_state_context_names(javascript_code) == expected + + @pytest.mark.parametrize("content", ["", '["index",']) def test_read_stateful_pages_marker_recovers_legacy_corruption( tmp_path, mocker, content diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index e172515feb0..1020106272f 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -198,6 +198,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "reflex___state____state__sub": {"value": 2}, }, state_name="reflex___state____state", + legacy_state_contexts={"reflex___state____state__sub"}, ).replace("function ClientStateProvider", "export function ClientStateProvider") react_stub = """ let hookIndex = 0; @@ -277,10 +278,15 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): react.setContextValue(registry.StateStoreContext, store); const first = registry.useStateContext("reflex___state____state"); const second = registry.useStateContext("reflex___state____state__sub"); +const dispatchElement = storeElement.props.children; +const legacyElement = dispatchElement.props.children; +const legacyContext = legacyElement.type(legacyElement.props); let firstNotifications = 0; let secondNotifications = 0; +let legacyNotifications = 0; react.subscriptions[0](() => firstNotifications++); react.subscriptions[1](() => secondNotifications++); +react.subscriptions[2](() => legacyNotifications++); store.dispatchers["reflex___state____state__sub"]({ value: 2 }); store.dispatchers["reflex___state____state__sub"]({}); const noOpIdentityPreserved = @@ -288,10 +294,15 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const notificationsAfterNoOps = secondNotifications; store.dispatchers["reflex___state____state__sub"]({ value: 3 }); const updated = registry.useStateContext("reflex___state____state__sub"); +const updatedLegacyContext = legacyElement.type(legacyElement.props); process.stdout.write(JSON.stringify({ first, second, updated, + legacyContext: legacyContext.props.value, + updatedLegacyContext: updatedLegacyContext.props.value, + legacyContextMatches: legacyContext.type === mod.StateContexts.reflex___state____state__sub, + legacyNotifications, noOpIdentityPreserved, notificationsAfterNoOps, firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, @@ -311,6 +322,10 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "first": {"value": 1}, "second": {"value": 2}, "updated": {"value": 3}, + "legacyContext": {"value": 2}, + "updatedLegacyContext": {"value": 3}, + "legacyContextMatches": True, + "legacyNotifications": 1, "noOpIdentityPreserved": True, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, From 2ec065b8114faa7c2326712d71db6b3b070a2ca9 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 10 Oct 2026 13:45:42 +0530 Subject: [PATCH 09/20] fix: keep legacy state context reads compatible --- .../.templates/web/utils/context-registry.js | 11 ++ .../src/reflex_base/compiler/templates.py | 27 +--- reflex/compiler/compiler.py | 149 ++++++++++++++---- tests/units/compiler/test_compiler.py | 57 +++++-- .../compiler/test_context_js_template.py | 18 ++- 5 files changed, 187 insertions(+), 75 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index 44b3dbb7eb9..83449a5110d 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -40,6 +40,7 @@ export function getStateContext(name) { if (context === undefined) { context = createContext(null); context.displayName = `StateContext(${name})`; + context.stateName = name; stateContexts.set(name, context); } return context; @@ -61,6 +62,16 @@ export function useStateContext(name) { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } +/** + * Read a state through a legacy StateContexts entry using the shared store. + * + * @param {React.Context} context - The named state's stable context handle. + * @returns {object} The current state value. + */ +export function useLegacyStateContext(context) { + return useStateContext(context.stateName); +} + // Values the generated context module registers each time it executes. The // static runtime reads them through this object at call time instead of // importing the generated module, so a compile never re-executes the runtime. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 51bd2dfbb96..27d200a39c4 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -315,7 +315,6 @@ def context_template( state_name: str | None = None, client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, disable_react_owner_stacks: bool = False, - legacy_state_contexts: set[str] | None = None, ): """Template for the context file. @@ -332,8 +331,6 @@ def context_template( 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). - legacy_state_contexts: Formatted state names read through the legacy - ``useContext(StateContexts.)`` API. Returns: Rendered context file content as string. @@ -347,14 +344,6 @@ def context_template( f'{format_state_name(state_name)}: getStateContext("{state_name}"),' for state_name in initial_state ]) - legacy_contexts_str = ",".join( - f"[{json.dumps(state_name)}, " - f"StateContexts[{json.dumps(format_state_name(state_name))}]]" - for state_name in initial_state - if "*" in (legacy_state_contexts or set()) - or format_state_name(state_name) in (legacy_state_contexts or set()) - ) - state_str = ( rf""" export const state_name = "{state_name}" @@ -583,27 +572,13 @@ def context_template( }} function StateStoreProviders({{ children, store }}) {{ - let stateTree = children; - for (const [stateName, context] of LEGACY_STATE_CONTEXTS.slice().reverse()) {{ - stateTree = createElement( - LegacyStateContextProvider, - {{ children: stateTree, stateName, context }}, - ); - }} return createElement( StateStoreContext, {{ value: store }}, - createElement(DispatchContext, {{ value: store.dispatchers }}, stateTree), + createElement(DispatchContext, {{ value: store.dispatchers }}, children), ); }} -const LEGACY_STATE_CONTEXTS = [{legacy_contexts_str}]; - -function LegacyStateContextProvider({{ children, stateName, context }}) {{ - const state = useStateContext(stateName); - return createElement(context, {{ value: state }}, children); -}} - function ClientStateProvider({{ children }}) {{ const [store] = useState(createStateStore); return createElement(StateStoreProviders, {{ children, store }}); diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 395524a21cd..2469234fb7a 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -246,7 +246,6 @@ def _compile_contexts( theme: Component | None, *, component_imports: ParsedImportDict | None = None, - legacy_state_contexts: set[str] | None = None, ) -> str: """Compile the initial state and contexts. @@ -254,7 +253,6 @@ def _compile_contexts( state: The app state. theme: The top-level app theme. component_imports: Optional accumulator for initial component dependencies. - legacy_state_contexts: State context names used through the legacy React API. Returns: The compiled context file. @@ -279,14 +277,12 @@ def _compile_contexts( is_dev_mode=not is_prod_mode(), default_color_mode=default_color_mode, disable_react_owner_stacks=disable_react_owner_stacks, - legacy_state_contexts=legacy_state_contexts, ) if state and initial_state is not None 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, - legacy_state_contexts=legacy_state_contexts, ) ) @@ -770,7 +766,6 @@ def compile_contexts( theme: Component | None, *, component_imports: ParsedImportDict | None = None, - legacy_state_contexts: set[str] | None = None, ) -> tuple[str, str]: """Compile the initial state / context. @@ -778,7 +773,6 @@ def compile_contexts( state: The app state. theme: The top-level app theme. component_imports: Optional accumulator for initial component dependencies. - legacy_state_contexts: State context names used through the legacy React API. Returns: The path and code of the compiled context. @@ -790,30 +784,117 @@ def compile_contexts( state, theme, component_imports=component_imports, - legacy_state_contexts=legacy_state_contexts, ) -def _legacy_state_context_names(javascript_code: str) -> set[str]: - """Find state contexts read through the pre-store React context API.""" - context_names = set( +def _mask_javascript_literals_and_comments(javascript_code: str) -> str: + """Mask strings and comments while preserving JavaScript source offsets.""" + masked = list(javascript_code) + index = 0 + quote: str | None = None + quote_start = 0 + while index < len(javascript_code): + char = javascript_code[index] + if quote is not None: + if char not in "\r\n": + masked[index] = " " + if char == "\\": + index += 1 + if ( + index < len(javascript_code) + and javascript_code[index] not in "\r\n" + ): + masked[index] = " " + elif char == quote: + if javascript_code[quote_start : index + 1] in ('"react"', "'react'"): + masked[quote_start : index + 1] = javascript_code[ + quote_start : index + 1 + ] + quote = None + elif char in "'\"`": + quote = char + quote_start = index + masked[index] = " " + elif javascript_code.startswith("//", index): + while index < len(javascript_code) and javascript_code[index] not in "\r\n": + masked[index] = " " + index += 1 + continue + elif javascript_code.startswith("/*", index): + masked[index : index + 2] = " " + index += 2 + while index < len(javascript_code) and not javascript_code.startswith( + "*/", index + ): + if javascript_code[index] not in "\r\n": + masked[index] = " " + index += 1 + if index < len(javascript_code): + masked[index : index + 2] = " " + index += 2 + continue + index += 1 + return "".join(masked) + + +def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: + """Read legacy StateContexts entries from the shared state store.""" + if "useContext" not in javascript_code or "StateContexts" not in javascript_code: + return javascript_code + code_mask = _mask_javascript_literals_and_comments(javascript_code) + direct_hooks: set[str] = set() + namespace_hooks: set[str] = set( re.findall( - r"\buseContext\s*\(\s*StateContexts\.([A-Za-z_$][\w$]*)\s*\)", - javascript_code, + r"import\s+\*\s+as\s+([\w$]+)\s+from\s*['\"]react['\"]", + code_mask, ) ) - context_names.update( - re.findall( - r"\buseContext\s*\(\s*StateContexts\[['\"]([^'\"]+)['\"]\]\s*\)", - javascript_code, + for match in re.finditer( + r"import\s+(?:(?P[\w$]+)\s*,?\s*)?(?:\{(?P[^}]*)\})?" + r"\s*from\s*['\"]react['\"]", + code_mask, + ): + if match.group("default"): + namespace_hooks.add(match.group("default")) + for alias in re.findall( + r"(?:^|,)\s*useContext(?:\s+as\s+([\w$]+))?\s*(?=,|$)", + match.group("named") or "", + ): + direct_hooks.add(alias or "useContext") + + callees = [rf"(?StateContexts\s*(?:\.\s*[\w$]+|\[\s*[^\[\]]+\s*\]))\s*\)" + ) + matches = list(pattern.finditer(code_mask)) + if not matches: + return javascript_code + + rewritten = [] + last_end = 0 + for match in matches: + start, end = match.span() + context_start, context_end = match.span("context") + rewritten.extend( + ( + javascript_code[last_end:start], + "useLegacyStateContext(" + + javascript_code[context_start:context_end] + + ")", + ) ) + last_end = end + rewritten.append(javascript_code[last_end:]) + return ( + 'import { useLegacyStateContext } from "$/utils/context-registry";\n' + + "".join(rewritten) ) - if re.search( - r"\buseContext\s*\(\s*StateContexts\s*\[\s*(?!['\"])", - javascript_code, - ): - context_names.add("*") - return context_names def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: @@ -1522,19 +1603,23 @@ def add_save_task( compile_results.append(result) progress.advance(task) - app_root_result = compile_app_root(app_root, hydrate_fallback_export) - legacy_state_contexts = set().union( - *(_legacy_state_context_names(code) for _, code in compile_results) + context_result = compile_contexts( + app._state, + radix_themes_plugin.get_theme(), + component_imports=all_imports, + ) + compile_results = [ + (output_path, _rewrite_legacy_state_context_reads(code)) + for output_path, code in compile_results + ] + app_root_path, app_root_code = compile_app_root(app_root, hydrate_fallback_export) + app_root_result = ( + app_root_path, + _rewrite_legacy_state_context_reads(app_root_code), ) - legacy_state_contexts.update(_legacy_state_context_names(app_root_result[1])) compile_results.extend( [ - compile_contexts( - app._state, - radix_themes_plugin.get_theme(), - component_imports=all_imports, - legacy_state_contexts=legacy_state_contexts, - ), + context_result, utils._compile_bundled_libraries(), app_root_result, ] diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 7d8714486b5..393c91e5c35 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -33,20 +33,59 @@ @pytest.mark.parametrize( - ("javascript_code", "expected"), + ("javascript_code", "expected_call"), [ - ("useContext(StateContexts.state)", {"state"}), ( - 'useContext(StateContexts["state"]); useContext(StateContexts.other)', - {"state", "other"}, + 'import { useContext } from "react"; useContext(StateContexts.state)', + "useLegacyStateContext(StateContexts.state)", + ), + ( + 'import { useContext as useReactContext } from "react"; ' + 'useReactContext(StateContexts[ "state" ])', + 'useLegacyStateContext(StateContexts[ "state" ])', + ), + ( + 'import * as React from "react"; ' + "React.useContext(StateContexts[stateName])", + "useLegacyStateContext(StateContexts[stateName])", + ), + ( + 'import React from "react"; React.useContext(StateContexts.state)', + "useLegacyStateContext(StateContexts.state)", ), - ("useContext(StateContexts[stateName])", {"*"}), - ('useStateContext("state")', set()), ], ) -def test_legacy_state_context_names(javascript_code: str, expected: set[str]) -> None: - """Identify legacy context reads so generated providers can preserve them.""" - assert compiler._legacy_state_context_names(javascript_code) == expected +def test_legacy_state_context_reads_use_shared_store( + javascript_code: str, expected_call: str +) -> None: + """Rewrite supported legacy context reads to subscribe to the shared store.""" + rewritten = compiler._rewrite_legacy_state_context_reads(javascript_code) + + assert expected_call in rewritten + assert 'from "$/utils/context-registry"' in rewritten + + +def test_legacy_state_context_rewrite_leaves_non_react_calls_unchanged() -> None: + """Do not rewrite unrelated functions named useContext.""" + source = "function useContext(value) {} useContext(StateContexts.state)" + + assert compiler._rewrite_legacy_state_context_reads(source) == source + + +def test_legacy_state_context_rewrite_preserves_strings_and_comments() -> None: + """Only rewrite executable React calls, not matching text in source trivia.""" + source = ( + 'import { useContext } from "react"; ' + 'const label = "useContext(StateContexts.text)"; ' + "// useContext(StateContexts.comment)\n" + "useContext(StateContexts.active)" + ) + + rewritten = compiler._rewrite_legacy_state_context_reads(source) + + assert '"useContext(StateContexts.text)"' in rewritten + assert "// useContext(StateContexts.comment)" in rewritten + assert "useLegacyStateContext(StateContexts.active)" in rewritten @pytest.mark.parametrize("content", ["", '["index",']) diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 1020106272f..7b56372102a 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -198,7 +198,6 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "reflex___state____state__sub": {"value": 2}, }, state_name="reflex___state____state", - legacy_state_contexts={"reflex___state____state__sub"}, ).replace("function ClientStateProvider", "export function ClientStateProvider") react_stub = """ let hookIndex = 0; @@ -278,9 +277,9 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): react.setContextValue(registry.StateStoreContext, store); const first = registry.useStateContext("reflex___state____state"); const second = registry.useStateContext("reflex___state____state__sub"); -const dispatchElement = storeElement.props.children; -const legacyElement = dispatchElement.props.children; -const legacyContext = legacyElement.type(legacyElement.props); +const legacyContext = registry.useLegacyStateContext( + mod.StateContexts.reflex___state____state__sub, +); let firstNotifications = 0; let secondNotifications = 0; let legacyNotifications = 0; @@ -294,14 +293,17 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const notificationsAfterNoOps = secondNotifications; store.dispatchers["reflex___state____state__sub"]({ value: 3 }); const updated = registry.useStateContext("reflex___state____state__sub"); -const updatedLegacyContext = legacyElement.type(legacyElement.props); +const updatedLegacyContext = registry.useLegacyStateContext( + mod.StateContexts.reflex___state____state__sub, +); process.stdout.write(JSON.stringify({ first, second, updated, - legacyContext: legacyContext.props.value, - updatedLegacyContext: updatedLegacyContext.props.value, - legacyContextMatches: legacyContext.type === mod.StateContexts.reflex___state____state__sub, + legacyContext, + updatedLegacyContext, + legacyContextMatches: mod.StateContexts.reflex___state____state__sub.stateName === + "reflex___state____state__sub", legacyNotifications, noOpIdentityPreserved, notificationsAfterNoOps, From 009ca8921526871ab80fbfabd9064fb31d0dde73 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sat, 10 Oct 2026 02:57:41 -0700 Subject: [PATCH 10/20] Fix compiler pre-commit checks for legacy context rewrite --- reflex/compiler/compiler.py | 54 ++++++++++++++++----------- tests/units/compiler/test_compiler.py | 12 ++++-- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 2469234fb7a..4292d231147 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -788,7 +788,14 @@ def compile_contexts( def _mask_javascript_literals_and_comments(javascript_code: str) -> str: - """Mask strings and comments while preserving JavaScript source offsets.""" + """Mask strings and comments while preserving JavaScript source offsets. + + Args: + javascript_code: The JavaScript source to mask. + + Returns: + Source with literals and comments masked at their original offsets. + """ masked = list(javascript_code) index = 0 quote: str | None = None @@ -838,7 +845,14 @@ def _mask_javascript_literals_and_comments(javascript_code: str) -> str: def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: - """Read legacy StateContexts entries from the shared state store.""" + """Read legacy StateContexts entries from the shared state store. + + Args: + javascript_code: The JavaScript source to rewrite. + + Returns: + Source with supported legacy context reads using the shared store. + """ if "useContext" not in javascript_code or "StateContexts" not in javascript_code: return javascript_code code_mask = _mask_javascript_literals_and_comments(javascript_code) @@ -856,11 +870,13 @@ def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: ): if match.group("default"): namespace_hooks.add(match.group("default")) - for alias in re.findall( - r"(?:^|,)\s*useContext(?:\s+as\s+([\w$]+))?\s*(?=,|$)", - match.group("named") or "", - ): - direct_hooks.add(alias or "useContext") + direct_hooks.update( + alias or "useContext" + for alias in re.findall( + r"(?:^|,)\s*useContext(?:\s+as\s+([\w$]+))?\s*(?=,|$)", + match.group("named") or "", + ) + ) callees = [rf"(? str: for match in matches: start, end = match.span() context_start, context_end = match.span("context") - rewritten.extend( - ( - javascript_code[last_end:start], - "useLegacyStateContext(" - + javascript_code[context_start:context_end] - + ")", - ) - ) + rewritten.extend(( + javascript_code[last_end:start], + "useLegacyStateContext(" + javascript_code[context_start:context_end] + ")", + )) last_end = end rewritten.append(javascript_code[last_end:]) return ( @@ -1617,13 +1629,11 @@ def add_save_task( app_root_path, _rewrite_legacy_state_context_reads(app_root_code), ) - compile_results.extend( - [ - context_result, - utils._compile_bundled_libraries(), - app_root_result, - ] - ) + compile_results.extend([ + context_result, + utils._compile_bundled_libraries(), + app_root_result, + ]) progress.advance(task) progress.stop() diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 393c91e5c35..55a07a6db07 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -40,13 +40,17 @@ "useLegacyStateContext(StateContexts.state)", ), ( - 'import { useContext as useReactContext } from "react"; ' - 'useReactContext(StateContexts[ "state" ])', + ( + 'import { useContext as useReactContext } from "react"; ' + 'useReactContext(StateContexts[ "state" ])' + ), 'useLegacyStateContext(StateContexts[ "state" ])', ), ( - 'import * as React from "react"; ' - "React.useContext(StateContexts[stateName])", + ( + 'import * as React from "react"; ' + "React.useContext(StateContexts[stateName])" + ), "useLegacyStateContext(StateContexts[stateName])", ), ( From 543b086c6354ba7f5783e3ebd372519964ffd55e Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 11:26:22 +0530 Subject: [PATCH 11/20] Install greenlet in legacy playground check --- .github/workflows/examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index c34ec5b014a..d1ed9b11493 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -82,7 +82,7 @@ jobs: - name: Install reflex 0.8.23 into a fresh venv run: | uv venv --python 3.12 "$RUNNER_TEMP/reflex-0.8.23" - uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" + uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" greenlet echo "$RUNNER_TEMP/reflex-0.8.23/bin" >> "$GITHUB_PATH" - name: Show the reflex under test working-directory: examples/playground From ef580fe8437e9cb6462ccbc6a2079c19dd527cdc Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 12:07:29 +0530 Subject: [PATCH 12/20] Address PR 7441 review feedback --- .../.templates/web/utils/context-registry.js | 5 +- .../src/reflex_base/compiler/templates.py | 35 +++- .../reflex-base/src/reflex_base/registry.py | 1 + .../reflex-base/src/reflex_base/vars/base.py | 4 +- reflex/compiler/compiler.py | 158 +++++++++++++----- tests/units/compiler/test_compiler.py | 14 ++ .../compiler/test_context_js_template.py | 21 +++ .../reflex_base/components/test_dynamic.py | 2 + 8 files changed, 185 insertions(+), 55 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index 83449a5110d..24475cff7ee 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -58,7 +58,10 @@ export function useStateContext(name) { (listener) => store.subscribe(name, listener), [store, name], ); - const getSnapshot = useCallback(() => store.getSnapshot(name), [store, name]); + const getSnapshot = useCallback( + () => store.getSnapshot(name) ?? app.initialState?.[name], + [store, name], + ); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 27d200a39c4..54fbf99a32b 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -534,13 +534,17 @@ def context_template( // consistent while notifying only consumers of the changed substate. function createStateStore() {{ const state = {{ ...initialState }}; - const listeners = new Map( - Object.keys(state).map((substateName) => [substateName, new Set()]), - ); - const dispatchers = Object.fromEntries( - Object.keys(state).map((substateName) => [ - substateName, - (delta) => {{ + const listeners = new Map(); + const dispatchers = {{}}; + const registerSubstate = (substateName, initialValue) => {{ + if (!listeners.has(substateName)) {{ + listeners.set(substateName, new Set()); + }} + if (!Object.hasOwn(state, substateName)) {{ + state[substateName] = initialValue; + }} + if (!Object.hasOwn(dispatchers, substateName)) {{ + dispatchers[substateName] = (delta) => {{ const currentState = state[substateName]; if ( Object.keys(delta).every((key) => @@ -552,12 +556,24 @@ def context_template( const nextState = applyDelta(currentState, delta); state[substateName] = nextState; listeners.get(substateName)?.forEach((listener) => listener()); - }}, - ]), + }}; + }} + }}; + Object.entries(state).forEach(([substateName, value]) => + registerSubstate(substateName, value), ); return {{ dispatchers, + syncInitialState: (nextInitialState) => {{ + Object.entries(nextInitialState).forEach(([substateName, value]) => {{ + const isNewSubstate = !Object.hasOwn(state, substateName); + registerSubstate(substateName, value); + if (isNewSubstate) {{ + listeners.get(substateName)?.forEach((listener) => listener()); + }} + }}); + }}, getSnapshot: (substateName) => state[substateName], subscribe: (substateName, listener) => {{ let substateListeners = listeners.get(substateName); @@ -581,6 +597,7 @@ def context_template( function ClientStateProvider({{ children }}) {{ const [store] = useState(createStateStore); + useEffect(() => store.syncInitialState(initialState), [store, initialState]); return createElement(StateStoreProviders, {{ children, store }}); }} diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 67a8fc4a464..05fb5c694da 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -32,6 +32,7 @@ def _default_bundled_libraries() -> list[str]: "react", "@emotion/react", f"$/{constants.Dirs.CONTEXTS_PATH}", + f"$/{constants.Dirs.UTILS}/context-registry", f"$/{constants.Dirs.STATE_PATH}", ] diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 1ddafe2636c..0671d80c917 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -644,7 +644,9 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa f"const {state_var_name} = useStateContext({json.dumps(state_name)})": None }, imports={ - "$/utils/context-registry": [ImportVar(tag="useStateContext")], + f"$/{constants.Dirs.UTILS}/context-registry": [ + ImportVar(tag="useStateContext") + ], }, # State Vars read the shared store/``EventLoopContext``, so the # providers must enclose every component that uses them. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 4292d231147..7266e3a905e 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -797,50 +797,87 @@ def _mask_javascript_literals_and_comments(javascript_code: str) -> str: Source with literals and comments masked at their original offsets. """ masked = list(javascript_code) - index = 0 - quote: str | None = None - quote_start = 0 - while index < len(javascript_code): - char = javascript_code[index] - if quote is not None: - if char not in "\r\n": + + def mask_code(index: int, stop_at_template_brace: bool = False) -> int: + brace_depth = 1 if stop_at_template_brace else 0 + while index < len(javascript_code): + char = javascript_code[index] + if char in "'\"": + quote_start = index + quote = char + index += 1 + while index < len(javascript_code): + char = javascript_code[index] + if char not in "\r\n": + masked[index] = " " + if char == "\\": + index += 1 + if ( + index < len(javascript_code) + and javascript_code[index] not in "\r\n" + ): + masked[index] = " " + elif char == quote: + index += 1 + break + index += 1 + if javascript_code[quote_start:index] in ('"react"', "'react'"): + masked[quote_start:index] = javascript_code[quote_start:index] + continue + if char == "`": masked[index] = " " - if char == "\\": index += 1 - if ( + while index < len(javascript_code): + char = javascript_code[index] + if char == "\\": + masked[index] = " " + index += 1 + if index < len(javascript_code): + masked[index] = " " + index += 1 + elif javascript_code.startswith("${", index): + index = mask_code(index + 2, stop_at_template_brace=True) + elif char == "`": + masked[index] = " " + index += 1 + break + else: + if char not in "\r\n": + masked[index] = " " + index += 1 + continue + if javascript_code.startswith("//", index): + while ( index < len(javascript_code) and javascript_code[index] not in "\r\n" ): masked[index] = " " - elif char == quote: - if javascript_code[quote_start : index + 1] in ('"react"', "'react'"): - masked[quote_start : index + 1] = javascript_code[ - quote_start : index + 1 - ] - quote = None - elif char in "'\"`": - quote = char - quote_start = index - masked[index] = " " - elif javascript_code.startswith("//", index): - while index < len(javascript_code) and javascript_code[index] not in "\r\n": - masked[index] = " " - index += 1 - continue - elif javascript_code.startswith("/*", index): - masked[index : index + 2] = " " - index += 2 - while index < len(javascript_code) and not javascript_code.startswith( - "*/", index - ): - if javascript_code[index] not in "\r\n": - masked[index] = " " - index += 1 - if index < len(javascript_code): + index += 1 + continue + if javascript_code.startswith("/*", index): masked[index : index + 2] = " " index += 2 - continue - index += 1 + while index < len(javascript_code) and not javascript_code.startswith( + "*/", index + ): + if javascript_code[index] not in "\r\n": + masked[index] = " " + index += 1 + if index < len(javascript_code): + masked[index : index + 2] = " " + index += 2 + continue + if stop_at_template_brace: + if char == "{": + brace_depth += 1 + elif char == "}": + brace_depth -= 1 + if brace_depth == 0: + return index + 1 + index += 1 + return index + + mask_code(0) return "".join(masked) @@ -884,19 +921,52 @@ def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: ) if not callees: return javascript_code - pattern = re.compile( - rf"(?:{'|'.join(callees)})\s*\(\s*" - rf"(?PStateContexts\s*(?:\.\s*[\w$]+|\[\s*[^\[\]]+\s*\]))\s*\)" + call_pattern = re.compile( + rf"(?:{'|'.join(callees)})\s*\(\s*(?PStateContexts)\b" ) - matches = list(pattern.finditer(code_mask)) + matches: list[tuple[int, int, int, int]] = [] + for call_match in call_pattern.finditer(code_mask): + context_start, context_end = call_match.span("context") + index = context_end + while index < len(code_mask) and code_mask[index].isspace(): + index += 1 + if index < len(code_mask) and code_mask[index] == ".": + index += 1 + while index < len(code_mask) and code_mask[index].isspace(): + index += 1 + property_match = re.match(r"[\w$]+", code_mask[index:]) + if property_match is None: + continue + index += property_match.end() + elif index < len(code_mask) and code_mask[index] == "[": + bracket_depth = 1 + index += 1 + expression_start = index + while index < len(code_mask) and bracket_depth: + if code_mask[index] == "[": + bracket_depth += 1 + elif code_mask[index] == "]": + bracket_depth -= 1 + index += 1 + if ( + bracket_depth + or not javascript_code[expression_start : index - 1].strip() + ): + continue + else: + continue + + context_end = index + while index < len(code_mask) and code_mask[index].isspace(): + index += 1 + if index < len(code_mask) and code_mask[index] == ")": + matches.append((call_match.start(), index + 1, context_start, context_end)) if not matches: return javascript_code rewritten = [] last_end = 0 - for match in matches: - start, end = match.span() - context_start, context_end = match.span("context") + for start, end, context_start, context_end in matches: rewritten.extend(( javascript_code[last_end:start], "useLegacyStateContext(" + javascript_code[context_start:context_end] + ")", @@ -904,7 +974,7 @@ def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: last_end = end rewritten.append(javascript_code[last_end:]) return ( - 'import { useLegacyStateContext } from "$/utils/context-registry";\n' + f'import {{ useLegacyStateContext }} from "$/{constants.Dirs.UTILS}/context-registry";\n' + "".join(rewritten) ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 55a07a6db07..2e255e05fac 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -53,6 +53,20 @@ ), "useLegacyStateContext(StateContexts[stateName])", ), + ( + ( + 'import { useContext } from "react"; ' + "useContext(StateContexts[names[index]])" + ), + "useLegacyStateContext(StateContexts[names[index]])", + ), + ( + ( + 'import { useContext } from "react"; ' + "const label = `${useContext(StateContexts[names[index]])}`;" + ), + "useLegacyStateContext(StateContexts[names[index]])", + ), ( 'import React from "react"; React.useContext(StateContexts.state)', "useLegacyStateContext(StateContexts.state)", diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 7b56372102a..7840e2b958a 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -296,12 +296,29 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const updatedLegacyContext = registry.useLegacyStateContext( mod.StateContexts.reflex___state____state__sub, ); +registry.registerApp({ + initialState: { + ...mod.initialState, + reflex___state____state__new: { value: 4 }, + }, +}); +const hmrBeforeSync = registry.useStateContext("reflex___state____state__new"); +let hmrNotifications = 0; +react.subscriptions[5](() => hmrNotifications++); +store.syncInitialState(registry.app.initialState); +const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); +store.dispatchers["reflex___state____state__new"]({ value: 5 }); +const hmrUpdated = registry.useStateContext("reflex___state____state__new"); process.stdout.write(JSON.stringify({ first, second, updated, legacyContext, updatedLegacyContext, + hmrBeforeSync, + hmrAfterSync, + hmrUpdated, + hmrNotifications, legacyContextMatches: mod.StateContexts.reflex___state____state__sub.stateName === "reflex___state____state__sub", legacyNotifications, @@ -326,6 +343,10 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "updated": {"value": 3}, "legacyContext": {"value": 2}, "updatedLegacyContext": {"value": 3}, + "hmrBeforeSync": {"value": 4}, + "hmrAfterSync": {"value": 4}, + "hmrUpdated": {"value": 5}, + "hmrNotifications": 2, "legacyContextMatches": True, "legacyNotifications": 1, "noOpIdentityPreserved": True, diff --git a/tests/units/reflex_base/components/test_dynamic.py b/tests/units/reflex_base/components/test_dynamic.py index 2eb3203d8c2..5f05159f213 100644 --- a/tests/units/reflex_base/components/test_dynamic.py +++ b/tests/units/reflex_base/components/test_dynamic.py @@ -4,6 +4,7 @@ from typing import Any import pytest +from reflex_base import constants from reflex_base.components import dynamic from reflex_base.components.component import Component from reflex_base.registry import RegistrationContext, _default_bundled_libraries @@ -33,6 +34,7 @@ def test_bundled_libraries_shim_via_reflex_namespace(): def test_default_bundled_libraries_shim(): """The `DEFAULT_BUNDLED_LIBRARIES` shim returns the default library list.""" assert _default_bundled_libraries() == dynamic.DEFAULT_BUNDLED_LIBRARIES + assert f"$/{constants.Dirs.UTILS}/context-registry" in _default_bundled_libraries() def test_bundle_registrations_survive_fork_and_respect_explicit_reset(): From c84ded27c5b52c1f9e3547fa647fca14b42cba12 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 12:27:14 +0530 Subject: [PATCH 13/20] Fix remaining PR 7441 review findings --- docs/app/tests/frontend_quality.test.mjs | 6 +- .../src/reflex_base/compiler/templates.py | 9 +- reflex/compiler/compiler.py | 108 ++++++++++++++++-- tests/units/compiler/test_compiler.py | 15 +++ tests/units/compiler/test_compiler_utils.py | 1 + .../compiler/test_context_js_template.py | 12 +- 6 files changed, 135 insertions(+), 16 deletions(-) diff --git a/docs/app/tests/frontend_quality.test.mjs b/docs/app/tests/frontend_quality.test.mjs index 40f7bfe15c3..6466695926a 100644 --- a/docs/app/tests/frontend_quality.test.mjs +++ b/docs/app/tests/frontend_quality.test.mjs @@ -147,7 +147,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_ 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", + const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_context_registry", "utils_state", "loadFixture", rootCode.replace(/^import .*$/gm, "") .replace(/^export default function/gm, "function") .replace(/^export /gm, "") @@ -165,7 +165,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_ try { let calls = 0; globalThis.window = {}; - setup(window, react, {}, {}, {}, async () => {calls++; return {value: 42};}); + 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); @@ -179,7 +179,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_ let attempts = 0; globalThis.window = {}; - setup(window, react, {}, {}, {}, async () => { + setup(window, react, {}, {}, {}, {}, async () => { if (++attempts === 1) throw new Error("Temporary download failure"); return {value: 43}; }); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 54fbf99a32b..7d2f513a5c3 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -569,7 +569,14 @@ def context_template( Object.entries(nextInitialState).forEach(([substateName, value]) => {{ const isNewSubstate = !Object.hasOwn(state, substateName); registerSubstate(substateName, value); - if (isNewSubstate) {{ + const currentState = state[substateName]; + const addedFields = Object.fromEntries( + Object.entries(value).filter(([key]) => !Object.hasOwn(currentState, key)), + ); + if (Object.keys(addedFields).length > 0) {{ + state[substateName] = {{ ...currentState, ...addedFields }}; + }} + if (isNewSubstate || Object.keys(addedFields).length > 0) {{ listeners.get(substateName)?.forEach((listener) => listener()); }} }}); diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 7266e3a905e..26fca6ec93d 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -800,8 +800,28 @@ def _mask_javascript_literals_and_comments(javascript_code: str) -> str: def mask_code(index: int, stop_at_template_brace: bool = False) -> int: brace_depth = 1 if stop_at_template_brace else 0 + can_start_regex = True + regex_prefix_keywords = { + "await", + "case", + "delete", + "do", + "else", + "in", + "instanceof", + "new", + "of", + "return", + "throw", + "typeof", + "void", + "yield", + } while index < len(javascript_code): char = javascript_code[index] + if char.isspace(): + index += 1 + continue if char in "'\"": quote_start = index quote = char @@ -823,6 +843,7 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: index += 1 if javascript_code[quote_start:index] in ('"react"', "'react'"): masked[quote_start:index] = javascript_code[quote_start:index] + can_start_regex = False continue if char == "`": masked[index] = " " @@ -845,6 +866,7 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: if char not in "\r\n": masked[index] = " " index += 1 + can_start_regex = False continue if javascript_code.startswith("//", index): while ( @@ -854,6 +876,34 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: masked[index] = " " index += 1 continue + if char == "/" and can_start_regex: + regex_start = index + index += 1 + in_character_class = False + while index < len(javascript_code): + char = javascript_code[index] + if char in "\r\n": + break + if char == "\\": + index += 2 + continue + if char == "[": + in_character_class = True + elif char == "]": + in_character_class = False + elif char == "/" and not in_character_class: + index += 1 + while index < len(javascript_code) and ( + javascript_code[index].isalpha() + ): + index += 1 + break + index += 1 + for mask_index in range(regex_start, index): + if javascript_code[mask_index] not in "\r\n": + masked[mask_index] = " " + can_start_regex = False + continue if javascript_code.startswith("/*", index): masked[index : index + 2] = " " index += 2 @@ -867,6 +917,25 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: masked[index : index + 2] = " " index += 2 continue + if char.isalpha() or char in "_$": + token_start = index + index += 1 + while index < len(javascript_code) and ( + javascript_code[index].isalnum() or javascript_code[index] in "_$" + ): + index += 1 + can_start_regex = ( + javascript_code[token_start:index] in regex_prefix_keywords + ) + continue + if char.isdigit(): + index += 1 + while index < len(javascript_code) and ( + javascript_code[index].isalnum() or javascript_code[index] in "._" + ): + index += 1 + can_start_regex = False + continue if stop_at_template_brace: if char == "{": brace_depth += 1 @@ -874,6 +943,9 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: brace_depth -= 1 if brace_depth == 0: return index + 1 + can_start_regex = char in "([{,:;?=+-*%!~&|^<>" + if char == "/": + can_start_regex = True index += 1 return index @@ -964,18 +1036,34 @@ def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: if not matches: return javascript_code - rewritten = [] - last_end = 0 - for start, end, context_start, context_end in matches: - rewritten.extend(( - javascript_code[last_end:start], - "useLegacyStateContext(" + javascript_code[context_start:context_end] + ")", - )) - last_end = end - rewritten.append(javascript_code[last_end:]) + def rewrite_region(start: int, end: int) -> str: + region_matches = [ + match for match in matches if start <= match[0] and match[1] <= end + ] + outer_matches = [ + match + for match in region_matches + if not any( + other != match and other[0] <= match[0] and match[1] <= other[1] + for other in region_matches + ) + ] + rewritten = [] + last_end = start + for match_start, match_end, context_start, context_end in outer_matches: + rewritten.extend(( + javascript_code[last_end:match_start], + "useLegacyStateContext(" + + rewrite_region(context_start, context_end) + + ")", + )) + last_end = match_end + rewritten.append(javascript_code[last_end:end]) + return "".join(rewritten) + return ( f'import {{ useLegacyStateContext }} from "$/{constants.Dirs.UTILS}/context-registry";\n' - + "".join(rewritten) + + rewrite_region(0, len(javascript_code)) ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 2e255e05fac..4f6a5b41964 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -60,6 +60,13 @@ ), "useLegacyStateContext(StateContexts[names[index]])", ), + ( + ( + 'import { useContext } from "react"; ' + "useContext(StateContexts[names[useContext(StateContexts.selector).index]])" + ), + "useLegacyStateContext(StateContexts[names[useLegacyStateContext(StateContexts.selector).index]])", + ), ( ( 'import { useContext } from "react"; ' @@ -67,6 +74,14 @@ ), "useLegacyStateContext(StateContexts[names[index]])", ), + ( + ( + 'import { useContext } from "react"; ' + "const label = `${/[}]/.test(value) ? " + 'useContext(StateContexts.name) : ""}`;' + ), + "useLegacyStateContext(StateContexts.name)", + ), ( 'import React from "react"; React.useContext(StateContexts.state)', "useLegacyStateContext(StateContexts.state)", diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index 8098abab6cf..b92647a9677 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -65,6 +65,7 @@ def test_bundled_libraries_artifact_round_trip( "react", "@emotion/react", "$/utils/context", + f"$/{constants.Dirs.UTILS}/context-registry", "$/utils/state", "@radix-ui/themes", ] diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 7840e2b958a..1a7cc57b65d 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -299,14 +299,18 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): registry.registerApp({ initialState: { ...mod.initialState, + reflex___state____state__sub: { value: 2, added: 9 }, reflex___state____state__new: { value: 4 }, }, }); const hmrBeforeSync = registry.useStateContext("reflex___state____state__new"); let hmrNotifications = 0; react.subscriptions[5](() => hmrNotifications++); +let existingStateNotifications = 0; +react.subscriptions[1](() => existingStateNotifications++); store.syncInitialState(registry.app.initialState); const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); +const existingStateAfterSync = registry.useStateContext("reflex___state____state__sub"); store.dispatchers["reflex___state____state__new"]({ value: 5 }); const hmrUpdated = registry.useStateContext("reflex___state____state__new"); process.stdout.write(JSON.stringify({ @@ -318,7 +322,9 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): hmrBeforeSync, hmrAfterSync, hmrUpdated, + existingStateAfterSync, hmrNotifications, + existingStateNotifications, legacyContextMatches: mod.StateContexts.reflex___state____state__sub.stateName === "reflex___state____state__sub", legacyNotifications, @@ -346,12 +352,14 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "hmrBeforeSync": {"value": 4}, "hmrAfterSync": {"value": 4}, "hmrUpdated": {"value": 5}, + "existingStateAfterSync": {"value": 3, "added": 9}, "hmrNotifications": 2, + "existingStateNotifications": 1, "legacyContextMatches": True, - "legacyNotifications": 1, + "legacyNotifications": 2, "noOpIdentityPreserved": True, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, "firstNotifications": 0, - "secondNotifications": 1, + "secondNotifications": 2, } From 7a9cbebf889ecbd7b53d081aa043f96cf4144ca6 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 12:45:04 +0530 Subject: [PATCH 14/20] Fix legacy state read scanning and rewriting --- reflex/compiler/compiler.py | 74 ++++++++++++++++----------- tests/units/compiler/test_compiler.py | 26 ++++++++++ 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 26fca6ec93d..ea2f02063d5 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -801,6 +801,7 @@ def _mask_javascript_literals_and_comments(javascript_code: str) -> str: def mask_code(index: int, stop_at_template_brace: bool = False) -> int: brace_depth = 1 if stop_at_template_brace else 0 can_start_regex = True + member_access = False regex_prefix_keywords = { "await", "case", @@ -876,6 +877,19 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: masked[index] = " " index += 1 continue + if javascript_code.startswith("/*", index): + masked[index : index + 2] = " " + index += 2 + while index < len(javascript_code) and not javascript_code.startswith( + "*/", index + ): + if javascript_code[index] not in "\r\n": + masked[index] = " " + index += 1 + if index < len(javascript_code): + masked[index : index + 2] = " " + index += 2 + continue if char == "/" and can_start_regex: regex_start = index index += 1 @@ -903,19 +917,7 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: if javascript_code[mask_index] not in "\r\n": masked[mask_index] = " " can_start_regex = False - continue - if javascript_code.startswith("/*", index): - masked[index : index + 2] = " " - index += 2 - while index < len(javascript_code) and not javascript_code.startswith( - "*/", index - ): - if javascript_code[index] not in "\r\n": - masked[index] = " " - index += 1 - if index < len(javascript_code): - masked[index : index + 2] = " " - index += 2 + member_access = False continue if char.isalpha() or char in "_$": token_start = index @@ -924,9 +926,10 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: javascript_code[index].isalnum() or javascript_code[index] in "_$" ): index += 1 - can_start_regex = ( + can_start_regex = not member_access and ( javascript_code[token_start:index] in regex_prefix_keywords ) + member_access = False continue if char.isdigit(): index += 1 @@ -935,6 +938,7 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: ): index += 1 can_start_regex = False + member_access = False continue if stop_at_template_brace: if char == "{": @@ -943,6 +947,7 @@ def mask_code(index: int, stop_at_template_brace: bool = False) -> int: brace_depth -= 1 if brace_depth == 0: return index + 1 + member_access = char == "." can_start_regex = char in "([{,:;?=+-*%!~&|^<>" if char == "/": can_start_regex = True @@ -1036,25 +1041,36 @@ def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: if not matches: return javascript_code - def rewrite_region(start: int, end: int) -> str: - region_matches = [ - match for match in matches if start <= match[0] and match[1] <= end - ] - outer_matches = [ - match - for match in region_matches - if not any( - other != match and other[0] <= match[0] and match[1] <= other[1] - for other in region_matches - ) - ] + ordered_matches = sorted(matches, key=lambda match: (match[0], -match[1])) + children: dict[tuple[int, int, int, int], list[tuple[int, int, int, int]]] = { + match: [] for match in ordered_matches + } + root_matches = [] + nesting: list[tuple[int, int, int, int]] = [] + for match in ordered_matches: + while nesting and not ( + nesting[-1][0] < match[0] and match[1] <= nesting[-1][1] + ): + nesting.pop() + if nesting: + children[nesting[-1]].append(match) + else: + root_matches.append(match) + nesting.append(match) + + def rewrite_region( + start: int, + end: int, + region_matches: list[tuple[int, int, int, int]], + ) -> str: rewritten = [] last_end = start - for match_start, match_end, context_start, context_end in outer_matches: + for match in region_matches: + match_start, match_end, context_start, context_end = match rewritten.extend(( javascript_code[last_end:match_start], "useLegacyStateContext(" - + rewrite_region(context_start, context_end) + + rewrite_region(context_start, context_end, children[match]) + ")", )) last_end = match_end @@ -1063,7 +1079,7 @@ def rewrite_region(start: int, end: int) -> str: return ( f'import {{ useLegacyStateContext }} from "$/{constants.Dirs.UTILS}/context-registry";\n' - + rewrite_region(0, len(javascript_code)) + + rewrite_region(0, len(javascript_code), root_matches) ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 4f6a5b41964..a86f6211fec 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -86,6 +86,20 @@ 'import React from "react"; React.useContext(StateContexts.state)', "useLegacyStateContext(StateContexts.state)", ), + ( + ( + 'import { useContext } from "react"; /* path/to */ ' + "const value = useContext(StateContexts.state);" + ), + "useLegacyStateContext(StateContexts.state)", + ), + ( + ( + 'import { useContext } from "react"; ' + "const ratio = obj.return / count; useContext(StateContexts.foo)" + ), + "useLegacyStateContext(StateContexts.foo)", + ), ], ) def test_legacy_state_context_reads_use_shared_store( @@ -121,6 +135,18 @@ def test_legacy_state_context_rewrite_preserves_strings_and_comments() -> None: assert "useLegacyStateContext(StateContexts.active)" in rewritten +def test_legacy_state_context_rewrite_handles_many_independent_reads() -> None: + """Rewrite many independent reads without repeatedly scanning all matches.""" + reads = "; ".join( + f"useContext(StateContexts.state_{index})" for index in range(256) + ) + source = f'import {{ useContext }} from "react"; {reads}' + + rewritten = compiler._rewrite_legacy_state_context_reads(source) + + assert rewritten.count("useLegacyStateContext(StateContexts.state_") == 256 + + @pytest.mark.parametrize("content", ["", '["index",']) def test_read_stateful_pages_marker_recovers_legacy_corruption( tmp_path, mocker, content From 6f0117c785dff8c9019b0fe96d8bb24430d191b7 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 14 Oct 2026 21:30:03 +0500 Subject: [PATCH 15/20] fix: remove legacy StateContexts rewriter Drop the compile-time JavaScript rewriter that turned useContext(StateContexts.x) into a shared-store read. Reflex now generates useStateContext(...) for every state read on each compile, so the rewriter only served hand-written hooks. It missed dynamic components and asset JS, where legacy reads silently became null. Remove the StateContexts export and getStateContext as well, so any remaining legacy read fails the build with a missing-export error instead of reading null. Add a breaking news fragment with the migration to useStateContext. --- .../news/+state-contexts-removed.breaking.md | 1 + .../.templates/web/utils/context-registry.js | 28 -- .../src/reflex_base/compiler/templates.py | 11 +- reflex/compiler/compiler.py | 325 +----------------- reflex/compiler/utils.py | 2 +- reflex/state.py | 5 +- tests/units/compiler/test_compiler.py | 115 ------- .../compiler/test_context_js_template.py | 20 +- tests/units/test_var.py | 1 - 9 files changed, 15 insertions(+), 493 deletions(-) create mode 100644 packages/reflex-base/news/+state-contexts-removed.breaking.md diff --git a/packages/reflex-base/news/+state-contexts-removed.breaking.md b/packages/reflex-base/news/+state-contexts-removed.breaking.md new file mode 100644 index 00000000000..a74a2aa30a4 --- /dev/null +++ b/packages/reflex-base/news/+state-contexts-removed.breaking.md @@ -0,0 +1 @@ +`$/utils/context` no longer exports `StateContexts`. Custom hooks that read state with `useContext(StateContexts.)` must use `useStateContext("")` from `$/utils/context-registry`, for example `useStateContext(MyState.get_full_name())`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index 24475cff7ee..c57f9c6f85a 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -21,7 +21,6 @@ export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const EventLoopContext = createContext(null); export const StateStoreContext = createContext(null); -const stateContexts = new Map(); ColorModeContext.displayName = "ColorModeContext"; UploadFilesContext.displayName = "UploadFilesContext"; @@ -29,23 +28,6 @@ DispatchContext.displayName = "DispatchContext"; EventLoopContext.displayName = "EventLoopContext"; StateStoreContext.displayName = "StateStoreContext"; -/** - * Return the stable legacy context for a named Python state. - * - * @param {string} name - The dotted Python state name. - * @returns {React.Context} The named state's context object. - */ -export function getStateContext(name) { - let context = stateContexts.get(name); - if (context === undefined) { - context = createContext(null); - context.displayName = `StateContext(${name})`; - context.stateName = name; - stateContexts.set(name, context); - } - return context; -} - /** * Subscribe a component to one Python substate in the shared store. * @@ -65,16 +47,6 @@ export function useStateContext(name) { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } -/** - * Read a state through a legacy StateContexts entry using the shared store. - * - * @param {React.Context} context - The named state's stable context handle. - * @returns {object} The current state value. - */ -export function useLegacyStateContext(context) { - return useStateContext(context.stateName); -} - // Values the generated context module registers each time it executes. The // static runtime reads them through this object at call time instead of // importing the generated module, so a compile never re-executes the runtime. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 7d2f513a5c3..af1d9e90034 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -10,7 +10,7 @@ from reflex_base import constants from reflex_base.constants import Hooks from reflex_base.utils import memo_paths -from reflex_base.utils.format import format_state_name, json_dumps +from reflex_base.utils.format import json_dumps from reflex_base.vars.base import VarData if TYPE_CHECKING: @@ -338,12 +338,6 @@ def context_template( initial_state = initial_state or {} if initial_state_json is None: initial_state_json = json_dumps(initial_state) - # Context objects come from the static registry so they survive hot - # updates of this module; only the lookup table is generated here. - state_contexts_str = "".join([ - f'{format_state_name(state_name)}: getStateContext("{state_name}"),' - for state_name in initial_state - ]) state_str = ( rf""" export const state_name = "{state_name}" @@ -440,7 +434,7 @@ def context_template( return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react" import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" -import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, getStateContext, useStateContext, registerApp, eventLoop }} from "$/utils/context-registry" +import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, registerApp, eventLoop }} from "$/utils/context-registry" import {{ jsx }} from "@emotion/react"; {disable_owner_stacks_str} export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }}; @@ -448,7 +442,6 @@ def context_template( export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)} export const defaultColorMode = {default_color_mode} -export const StateContexts = {{{state_contexts_str}}}; export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} {state_str} diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index ea2f02063d5..757462f73c6 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -6,7 +6,6 @@ import dataclasses import json import logging -import re from collections.abc import Callable, Iterable, Sequence from inspect import getmodule from pathlib import Path @@ -781,305 +780,7 @@ def compile_contexts( output_path = utils.get_context_path() return output_path, _compile_contexts( - state, - theme, - component_imports=component_imports, - ) - - -def _mask_javascript_literals_and_comments(javascript_code: str) -> str: - """Mask strings and comments while preserving JavaScript source offsets. - - Args: - javascript_code: The JavaScript source to mask. - - Returns: - Source with literals and comments masked at their original offsets. - """ - masked = list(javascript_code) - - def mask_code(index: int, stop_at_template_brace: bool = False) -> int: - brace_depth = 1 if stop_at_template_brace else 0 - can_start_regex = True - member_access = False - regex_prefix_keywords = { - "await", - "case", - "delete", - "do", - "else", - "in", - "instanceof", - "new", - "of", - "return", - "throw", - "typeof", - "void", - "yield", - } - while index < len(javascript_code): - char = javascript_code[index] - if char.isspace(): - index += 1 - continue - if char in "'\"": - quote_start = index - quote = char - index += 1 - while index < len(javascript_code): - char = javascript_code[index] - if char not in "\r\n": - masked[index] = " " - if char == "\\": - index += 1 - if ( - index < len(javascript_code) - and javascript_code[index] not in "\r\n" - ): - masked[index] = " " - elif char == quote: - index += 1 - break - index += 1 - if javascript_code[quote_start:index] in ('"react"', "'react'"): - masked[quote_start:index] = javascript_code[quote_start:index] - can_start_regex = False - continue - if char == "`": - masked[index] = " " - index += 1 - while index < len(javascript_code): - char = javascript_code[index] - if char == "\\": - masked[index] = " " - index += 1 - if index < len(javascript_code): - masked[index] = " " - index += 1 - elif javascript_code.startswith("${", index): - index = mask_code(index + 2, stop_at_template_brace=True) - elif char == "`": - masked[index] = " " - index += 1 - break - else: - if char not in "\r\n": - masked[index] = " " - index += 1 - can_start_regex = False - continue - if javascript_code.startswith("//", index): - while ( - index < len(javascript_code) - and javascript_code[index] not in "\r\n" - ): - masked[index] = " " - index += 1 - continue - if javascript_code.startswith("/*", index): - masked[index : index + 2] = " " - index += 2 - while index < len(javascript_code) and not javascript_code.startswith( - "*/", index - ): - if javascript_code[index] not in "\r\n": - masked[index] = " " - index += 1 - if index < len(javascript_code): - masked[index : index + 2] = " " - index += 2 - continue - if char == "/" and can_start_regex: - regex_start = index - index += 1 - in_character_class = False - while index < len(javascript_code): - char = javascript_code[index] - if char in "\r\n": - break - if char == "\\": - index += 2 - continue - if char == "[": - in_character_class = True - elif char == "]": - in_character_class = False - elif char == "/" and not in_character_class: - index += 1 - while index < len(javascript_code) and ( - javascript_code[index].isalpha() - ): - index += 1 - break - index += 1 - for mask_index in range(regex_start, index): - if javascript_code[mask_index] not in "\r\n": - masked[mask_index] = " " - can_start_regex = False - member_access = False - continue - if char.isalpha() or char in "_$": - token_start = index - index += 1 - while index < len(javascript_code) and ( - javascript_code[index].isalnum() or javascript_code[index] in "_$" - ): - index += 1 - can_start_regex = not member_access and ( - javascript_code[token_start:index] in regex_prefix_keywords - ) - member_access = False - continue - if char.isdigit(): - index += 1 - while index < len(javascript_code) and ( - javascript_code[index].isalnum() or javascript_code[index] in "._" - ): - index += 1 - can_start_regex = False - member_access = False - continue - if stop_at_template_brace: - if char == "{": - brace_depth += 1 - elif char == "}": - brace_depth -= 1 - if brace_depth == 0: - return index + 1 - member_access = char == "." - can_start_regex = char in "([{,:;?=+-*%!~&|^<>" - if char == "/": - can_start_regex = True - index += 1 - return index - - mask_code(0) - return "".join(masked) - - -def _rewrite_legacy_state_context_reads(javascript_code: str) -> str: - """Read legacy StateContexts entries from the shared state store. - - Args: - javascript_code: The JavaScript source to rewrite. - - Returns: - Source with supported legacy context reads using the shared store. - """ - if "useContext" not in javascript_code or "StateContexts" not in javascript_code: - return javascript_code - code_mask = _mask_javascript_literals_and_comments(javascript_code) - direct_hooks: set[str] = set() - namespace_hooks: set[str] = set( - re.findall( - r"import\s+\*\s+as\s+([\w$]+)\s+from\s*['\"]react['\"]", - code_mask, - ) - ) - for match in re.finditer( - r"import\s+(?:(?P[\w$]+)\s*,?\s*)?(?:\{(?P[^}]*)\})?" - r"\s*from\s*['\"]react['\"]", - code_mask, - ): - if match.group("default"): - namespace_hooks.add(match.group("default")) - direct_hooks.update( - alias or "useContext" - for alias in re.findall( - r"(?:^|,)\s*useContext(?:\s+as\s+([\w$]+))?\s*(?=,|$)", - match.group("named") or "", - ) - ) - - callees = [rf"(?StateContexts)\b" - ) - matches: list[tuple[int, int, int, int]] = [] - for call_match in call_pattern.finditer(code_mask): - context_start, context_end = call_match.span("context") - index = context_end - while index < len(code_mask) and code_mask[index].isspace(): - index += 1 - if index < len(code_mask) and code_mask[index] == ".": - index += 1 - while index < len(code_mask) and code_mask[index].isspace(): - index += 1 - property_match = re.match(r"[\w$]+", code_mask[index:]) - if property_match is None: - continue - index += property_match.end() - elif index < len(code_mask) and code_mask[index] == "[": - bracket_depth = 1 - index += 1 - expression_start = index - while index < len(code_mask) and bracket_depth: - if code_mask[index] == "[": - bracket_depth += 1 - elif code_mask[index] == "]": - bracket_depth -= 1 - index += 1 - if ( - bracket_depth - or not javascript_code[expression_start : index - 1].strip() - ): - continue - else: - continue - - context_end = index - while index < len(code_mask) and code_mask[index].isspace(): - index += 1 - if index < len(code_mask) and code_mask[index] == ")": - matches.append((call_match.start(), index + 1, context_start, context_end)) - if not matches: - return javascript_code - - ordered_matches = sorted(matches, key=lambda match: (match[0], -match[1])) - children: dict[tuple[int, int, int, int], list[tuple[int, int, int, int]]] = { - match: [] for match in ordered_matches - } - root_matches = [] - nesting: list[tuple[int, int, int, int]] = [] - for match in ordered_matches: - while nesting and not ( - nesting[-1][0] < match[0] and match[1] <= nesting[-1][1] - ): - nesting.pop() - if nesting: - children[nesting[-1]].append(match) - else: - root_matches.append(match) - nesting.append(match) - - def rewrite_region( - start: int, - end: int, - region_matches: list[tuple[int, int, int, int]], - ) -> str: - rewritten = [] - last_end = start - for match in region_matches: - match_start, match_end, context_start, context_end = match - rewritten.extend(( - javascript_code[last_end:match_start], - "useLegacyStateContext(" - + rewrite_region(context_start, context_end, children[match]) - + ")", - )) - last_end = match_end - rewritten.append(javascript_code[last_end:end]) - return "".join(rewritten) - - return ( - f'import {{ useLegacyStateContext }} from "$/{constants.Dirs.UTILS}/context-registry";\n' - + rewrite_region(0, len(javascript_code), root_matches) + state, theme, component_imports=component_imports ) @@ -1789,27 +1490,19 @@ def add_save_task( compile_results.append(result) progress.advance(task) - context_result = compile_contexts( - app._state, - radix_themes_plugin.get_theme(), - component_imports=all_imports, - ) - compile_results = [ - (output_path, _rewrite_legacy_state_context_reads(code)) - for output_path, code in compile_results - ] - app_root_path, app_root_code = compile_app_root(app_root, hydrate_fallback_export) - app_root_result = ( - app_root_path, - _rewrite_legacy_state_context_reads(app_root_code), - ) compile_results.extend([ - context_result, + compile_contexts( + app._state, + radix_themes_plugin.get_theme(), + component_imports=all_imports, + ), utils._compile_bundled_libraries(), - app_root_result, ]) progress.advance(task) + compile_results.append(compile_app_root(app_root, hydrate_fallback_export)) + progress.advance(task) + progress.stop() if dry_run: diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 0dc84cdbe8e..8168fed9668 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -528,7 +528,7 @@ def compile_experimental_component_memo( ) render._imports_cache = analysis.imports # Strings returned by the root's ``add_hooks`` can reference symbols - # (``refs``, ``StateContexts``, etc.) that normally reach this module + # (``refs``, ``EventLoopContext``, etc.) that normally reach this module # through descendants' ``_get_hooks_imports`` / ``_get_imports``. JS # imports are side-effect-free and dedup cleanly, so pulling the # whole subtree's imports here is safe even when some go unused. diff --git a/reflex/state.py b/reflex/state.py index 6b65adb957b..bb460e3d99e 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2953,10 +2953,7 @@ def code_uses_state_contexts(javascript_code: str) -> bool: Returns: True if the code subscribes to a state context. """ - return bool( - "useStateContext(" in javascript_code - or "useContext(StateContexts" in javascript_code - ) + return "useStateContext(" in javascript_code def reload_state_module( diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index a86f6211fec..4e02b372b9b 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -32,121 +32,6 @@ from reflex.utils import prerequisites -@pytest.mark.parametrize( - ("javascript_code", "expected_call"), - [ - ( - 'import { useContext } from "react"; useContext(StateContexts.state)', - "useLegacyStateContext(StateContexts.state)", - ), - ( - ( - 'import { useContext as useReactContext } from "react"; ' - 'useReactContext(StateContexts[ "state" ])' - ), - 'useLegacyStateContext(StateContexts[ "state" ])', - ), - ( - ( - 'import * as React from "react"; ' - "React.useContext(StateContexts[stateName])" - ), - "useLegacyStateContext(StateContexts[stateName])", - ), - ( - ( - 'import { useContext } from "react"; ' - "useContext(StateContexts[names[index]])" - ), - "useLegacyStateContext(StateContexts[names[index]])", - ), - ( - ( - 'import { useContext } from "react"; ' - "useContext(StateContexts[names[useContext(StateContexts.selector).index]])" - ), - "useLegacyStateContext(StateContexts[names[useLegacyStateContext(StateContexts.selector).index]])", - ), - ( - ( - 'import { useContext } from "react"; ' - "const label = `${useContext(StateContexts[names[index]])}`;" - ), - "useLegacyStateContext(StateContexts[names[index]])", - ), - ( - ( - 'import { useContext } from "react"; ' - "const label = `${/[}]/.test(value) ? " - 'useContext(StateContexts.name) : ""}`;' - ), - "useLegacyStateContext(StateContexts.name)", - ), - ( - 'import React from "react"; React.useContext(StateContexts.state)', - "useLegacyStateContext(StateContexts.state)", - ), - ( - ( - 'import { useContext } from "react"; /* path/to */ ' - "const value = useContext(StateContexts.state);" - ), - "useLegacyStateContext(StateContexts.state)", - ), - ( - ( - 'import { useContext } from "react"; ' - "const ratio = obj.return / count; useContext(StateContexts.foo)" - ), - "useLegacyStateContext(StateContexts.foo)", - ), - ], -) -def test_legacy_state_context_reads_use_shared_store( - javascript_code: str, expected_call: str -) -> None: - """Rewrite supported legacy context reads to subscribe to the shared store.""" - rewritten = compiler._rewrite_legacy_state_context_reads(javascript_code) - - assert expected_call in rewritten - assert 'from "$/utils/context-registry"' in rewritten - - -def test_legacy_state_context_rewrite_leaves_non_react_calls_unchanged() -> None: - """Do not rewrite unrelated functions named useContext.""" - source = "function useContext(value) {} useContext(StateContexts.state)" - - assert compiler._rewrite_legacy_state_context_reads(source) == source - - -def test_legacy_state_context_rewrite_preserves_strings_and_comments() -> None: - """Only rewrite executable React calls, not matching text in source trivia.""" - source = ( - 'import { useContext } from "react"; ' - 'const label = "useContext(StateContexts.text)"; ' - "// useContext(StateContexts.comment)\n" - "useContext(StateContexts.active)" - ) - - rewritten = compiler._rewrite_legacy_state_context_reads(source) - - assert '"useContext(StateContexts.text)"' in rewritten - assert "// useContext(StateContexts.comment)" in rewritten - assert "useLegacyStateContext(StateContexts.active)" in rewritten - - -def test_legacy_state_context_rewrite_handles_many_independent_reads() -> None: - """Rewrite many independent reads without repeatedly scanning all matches.""" - reads = "; ".join( - f"useContext(StateContexts.state_{index})" for index in range(256) - ) - source = f'import {{ useContext }} from "react"; {reads}' - - rewritten = compiler._rewrite_legacy_state_context_reads(source) - - assert rewritten.count("useLegacyStateContext(StateContexts.state_") == 256 - - @pytest.mark.parametrize("content", ["", '["index",']) def test_read_stateful_pages_marker_recovers_legacy_corruption( tmp_path, mocker, content diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 1a7cc57b65d..5d6d78edf33 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -56,7 +56,6 @@ export const EventLoopContext = {}; export const StateStoreContext = { name: "StateStoreContext" }; StateStoreContext.Provider = StateStoreContext; -export const getStateContext = () => ({}); export const useStateContext = () => ({}); export const registerApp = () => {}; export const eventLoop = {}; @@ -277,15 +276,10 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): react.setContextValue(registry.StateStoreContext, store); const first = registry.useStateContext("reflex___state____state"); const second = registry.useStateContext("reflex___state____state__sub"); -const legacyContext = registry.useLegacyStateContext( - mod.StateContexts.reflex___state____state__sub, -); let firstNotifications = 0; let secondNotifications = 0; -let legacyNotifications = 0; react.subscriptions[0](() => firstNotifications++); react.subscriptions[1](() => secondNotifications++); -react.subscriptions[2](() => legacyNotifications++); store.dispatchers["reflex___state____state__sub"]({ value: 2 }); store.dispatchers["reflex___state____state__sub"]({}); const noOpIdentityPreserved = @@ -293,9 +287,6 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const notificationsAfterNoOps = secondNotifications; store.dispatchers["reflex___state____state__sub"]({ value: 3 }); const updated = registry.useStateContext("reflex___state____state__sub"); -const updatedLegacyContext = registry.useLegacyStateContext( - mod.StateContexts.reflex___state____state__sub, -); registry.registerApp({ initialState: { ...mod.initialState, @@ -305,7 +296,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): }); const hmrBeforeSync = registry.useStateContext("reflex___state____state__new"); let hmrNotifications = 0; -react.subscriptions[5](() => hmrNotifications++); +react.subscriptions[3](() => hmrNotifications++); let existingStateNotifications = 0; react.subscriptions[1](() => existingStateNotifications++); store.syncInitialState(registry.app.initialState); @@ -317,17 +308,12 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): first, second, updated, - legacyContext, - updatedLegacyContext, hmrBeforeSync, hmrAfterSync, hmrUpdated, existingStateAfterSync, hmrNotifications, existingStateNotifications, - legacyContextMatches: mod.StateContexts.reflex___state____state__sub.stateName === - "reflex___state____state__sub", - legacyNotifications, noOpIdentityPreserved, notificationsAfterNoOps, firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, @@ -347,16 +333,12 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "first": {"value": 1}, "second": {"value": 2}, "updated": {"value": 3}, - "legacyContext": {"value": 2}, - "updatedLegacyContext": {"value": 3}, "hmrBeforeSync": {"value": 4}, "hmrAfterSync": {"value": 4}, "hmrUpdated": {"value": 5}, "existingStateAfterSync": {"value": 3, "added": 9}, "hmrNotifications": 2, "existingStateNotifications": 1, - "legacyContextMatches": True, - "legacyNotifications": 2, "noOpIdentityPreserved": True, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 494172bf7aa..fe0639d53ac 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -1591,7 +1591,6 @@ def test_retrieval(): ("javascript_code", "expected"), [ ("const state = useStateContext('state')", True), - ("const state = useContext(StateContexts.state)", True), ("const state = useContext(ThemeContext)", False), ], ) From f534712894c1380bf4e6b01d26cf76f3d0a8adfd Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 14 Oct 2026 21:32:20 +0500 Subject: [PATCH 16/20] chore: drop breaking fragment for internal StateContexts removal --- packages/reflex-base/news/+state-contexts-removed.breaking.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/reflex-base/news/+state-contexts-removed.breaking.md diff --git a/packages/reflex-base/news/+state-contexts-removed.breaking.md b/packages/reflex-base/news/+state-contexts-removed.breaking.md deleted file mode 100644 index a74a2aa30a4..00000000000 --- a/packages/reflex-base/news/+state-contexts-removed.breaking.md +++ /dev/null @@ -1 +0,0 @@ -`$/utils/context` no longer exports `StateContexts`. Custom hooks that read state with `useContext(StateContexts.)` must use `useStateContext("")` from `$/utils/context-registry`, for example `useStateContext(MyState.get_full_name())`. From 1455221ab4be80b779cda3cd8cb52c98463a6fd5 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 14 Oct 2026 22:19:21 +0500 Subject: [PATCH 17/20] fix: address review findings on the shared state store - Raise reflex's reflex-base floor to 0.10.1.dev0: code_uses_state_contexts looks for useStateContext(, which only the new reflex-base generates. - Drop the unrelated greenlet change in examples.yml. - Compare applyDelta's result with the current state instead of pre-checking the delta, so a deep-equal delta no longer notifies. - Skip syncInitialState when the store already holds that initial state. - Serve the server render from a read-only store. - Remove the duplicate listener set creation and use one useMemo in useStateContext. - Mark the news fragments as bugfix with per-package text. - Make the JS store test use a structural-sharing applyDelta stub, subscribe by state name, and cover an equal list delta. --- .github/workflows/examples.yml | 2 +- news/7441.bugfix.md | 1 + news/7441.performance.md | 1 - .../news/+large-state-provider-tree.bugfix.md | 1 + .../+large-state-provider-tree.performance.md | 1 - .../.templates/web/utils/context-registry.js | 13 ++++---- .../src/reflex_base/compiler/templates.py | 27 ++++++++-------- pyproject.toml | 2 +- .../compiler/test_context_js_template.py | 32 +++++++++++++------ 9 files changed, 45 insertions(+), 35 deletions(-) create mode 100644 news/7441.bugfix.md delete mode 100644 news/7441.performance.md create mode 100644 packages/reflex-base/news/+large-state-provider-tree.bugfix.md delete mode 100644 packages/reflex-base/news/+large-state-provider-tree.performance.md diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index d1ed9b11493..c34ec5b014a 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -82,7 +82,7 @@ jobs: - name: Install reflex 0.8.23 into a fresh venv run: | uv venv --python 3.12 "$RUNNER_TEMP/reflex-0.8.23" - uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" greenlet + uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" echo "$RUNNER_TEMP/reflex-0.8.23/bin" >> "$GITHUB_PATH" - name: Show the reflex under test working-directory: examples/playground diff --git a/news/7441.bugfix.md b/news/7441.bugfix.md new file mode 100644 index 00000000000..2a400a11684 --- /dev/null +++ b/news/7441.bugfix.md @@ -0,0 +1 @@ +Detect state reads from the new shared state store when checking that an app that reads state defines an `rx.State` subclass. diff --git a/news/7441.performance.md b/news/7441.performance.md deleted file mode 100644 index b55047d5c81..00000000000 --- a/news/7441.performance.md +++ /dev/null @@ -1 +0,0 @@ -Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses. diff --git a/packages/reflex-base/news/+large-state-provider-tree.bugfix.md b/packages/reflex-base/news/+large-state-provider-tree.bugfix.md new file mode 100644 index 00000000000..2fd128f75e6 --- /dev/null +++ b/packages/reflex-base/news/+large-state-provider-tree.bugfix.md @@ -0,0 +1 @@ +Fix a blank page with `Maximum call stack size exceeded` in apps with thousands of state classes. State is now read from one shared store, so the React provider tree no longer grows with the number of states. diff --git a/packages/reflex-base/news/+large-state-provider-tree.performance.md b/packages/reflex-base/news/+large-state-provider-tree.performance.md deleted file mode 100644 index b55047d5c81..00000000000 --- a/packages/reflex-base/news/+large-state-provider-tree.performance.md +++ /dev/null @@ -1 +0,0 @@ -Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index c57f9c6f85a..06778dc0092 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js @@ -6,8 +6,8 @@ // nothing that can change and is never re-executed. import { createContext, - useCallback, useContext, + useMemo, useSyncExternalStore, } from "react"; @@ -36,12 +36,11 @@ StateStoreContext.displayName = "StateStoreContext"; */ export function useStateContext(name) { const store = useContext(StateStoreContext); - const subscribe = useCallback( - (listener) => store.subscribe(name, listener), - [store, name], - ); - const getSnapshot = useCallback( - () => store.getSnapshot(name) ?? app.initialState?.[name], + const [subscribe, getSnapshot] = useMemo( + () => [ + (listener) => store.subscribe(name, listener), + () => store.getSnapshot(name) ?? app.initialState?.[name], + ], [store, name], ); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index af1d9e90034..49f4ce84396 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -529,24 +529,16 @@ def context_template( const state = {{ ...initialState }}; const listeners = new Map(); const dispatchers = {{}}; + let syncedInitialState = initialState; const registerSubstate = (substateName, initialValue) => {{ - if (!listeners.has(substateName)) {{ - listeners.set(substateName, new Set()); - }} if (!Object.hasOwn(state, substateName)) {{ state[substateName] = initialValue; }} if (!Object.hasOwn(dispatchers, substateName)) {{ dispatchers[substateName] = (delta) => {{ const currentState = state[substateName]; - if ( - Object.keys(delta).every((key) => - Object.is(currentState[key], delta[key]), - ) - ) {{ - return; - }} const nextState = applyDelta(currentState, delta); + if (nextState === currentState) return; state[substateName] = nextState; listeners.get(substateName)?.forEach((listener) => listener()); }}; @@ -559,6 +551,8 @@ def context_template( return {{ dispatchers, syncInitialState: (nextInitialState) => {{ + if (nextInitialState === syncedInitialState) return; + syncedInitialState = nextInitialState; Object.entries(nextInitialState).forEach(([substateName, value]) => {{ const isNewSubstate = !Object.hasOwn(state, substateName); registerSubstate(substateName, value); @@ -601,11 +595,16 @@ def context_template( return createElement(StateStoreProviders, {{ children, store }}); }} +// The server renders once and never applies a delta, so it only reads the +// initial state. +const serverStateStore = {{ + dispatchers: {{}}, + getSnapshot: (substateName) => initialState[substateName], + subscribe: () => () => {{}}, +}}; + function ServerStateProvider({{ children }}) {{ - return createElement( - StateStoreProviders, - {{ children, store: createStateStore() }}, - ); + return createElement(StateStoreProviders, {{ children, store: serverStateStore }}); }} export const StateProvider = diff --git a/pyproject.toml b/pyproject.toml index e46f43cbd28..71671f49578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "starlette >=1.3.1", "typing_extensions >=4.13.0", "wrapt >=1.17.0,<2.6", - "reflex-base >= 0.10.0a2.dev0", + "reflex-base >= 0.10.1.dev0", "reflex-components-code >= 0.10.0", "reflex-components-core >= 0.10.0", "reflex-components-dataeditor >= 0.10.0", diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 5d6d78edf33..6f787bb5ccb 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -195,6 +195,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): initial_state={ "reflex___state____state": {"value": 1}, "reflex___state____state__sub": {"value": 2}, + "reflex___state____state__list": {"items": ["a"]}, }, state_name="reflex___state____state", ).replace("function ClientStateProvider", "export function ClientStateProvider") @@ -202,13 +203,9 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): let hookIndex = 0; let hookStates = []; const contextValues = new Map(); -export const subscriptions = []; export const useContext = (context) => contextValues.get(context); export const useCallback = (fn) => fn; -export const useSyncExternalStore = (subscribe, getSnapshot) => { - subscriptions.push(subscribe); - return getSnapshot(); -}; +export const useSyncExternalStore = (subscribe, getSnapshot) => getSnapshot(); export const useMemo = (fn) => fn(); export const useState = (initial) => { const index = hookIndex++; @@ -233,7 +230,17 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): export const setContextValue = (context, value) => contextValues.set(context, value); """ state_stub = """ -export const applyDelta = (state, delta) => ({ ...state, ...delta }); +// Shares structure like the real applyDelta: equal values keep the old object. +export const applyDelta = (state, delta) => { + let out; + for (const key in delta) { + if (JSON.stringify(state[key]) !== JSON.stringify(delta[key])) { + out ??= { ...state }; + out[key] = delta[key]; + } + } + return out ?? state; +}; export const ReflexEvent = () => ({}); export const hydrateClientStorage = () => ({}); export const useEventLoop = () => []; @@ -278,10 +285,13 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const second = registry.useStateContext("reflex___state____state__sub"); let firstNotifications = 0; let secondNotifications = 0; -react.subscriptions[0](() => firstNotifications++); -react.subscriptions[1](() => secondNotifications++); +let listNotifications = 0; +store.subscribe("reflex___state____state", () => firstNotifications++); +store.subscribe("reflex___state____state__sub", () => secondNotifications++); +store.subscribe("reflex___state____state__list", () => listNotifications++); store.dispatchers["reflex___state____state__sub"]({ value: 2 }); store.dispatchers["reflex___state____state__sub"]({}); +store.dispatchers["reflex___state____state__list"]({ items: ["a"] }); const noOpIdentityPreserved = store.getSnapshot("reflex___state____state__sub") === second; const notificationsAfterNoOps = secondNotifications; @@ -296,9 +306,9 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): }); const hmrBeforeSync = registry.useStateContext("reflex___state____state__new"); let hmrNotifications = 0; -react.subscriptions[3](() => hmrNotifications++); +store.subscribe("reflex___state____state__new", () => hmrNotifications++); let existingStateNotifications = 0; -react.subscriptions[1](() => existingStateNotifications++); +store.subscribe("reflex___state____state__sub", () => existingStateNotifications++); store.syncInitialState(registry.app.initialState); const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); const existingStateAfterSync = registry.useStateContext("reflex___state____state__sub"); @@ -316,6 +326,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): existingStateNotifications, noOpIdentityPreserved, notificationsAfterNoOps, + listNotifications, firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, firstNotifications, secondNotifications, @@ -340,6 +351,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "hmrNotifications": 2, "existingStateNotifications": 1, "noOpIdentityPreserved": True, + "listNotifications": 0, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, "firstNotifications": 0, From 99f9ed86935e08eb1a8f4bd56424b9db49182908 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 14 Oct 2026 22:26:44 +0500 Subject: [PATCH 18/20] test: subscribe through useStateContext in the store test Attach the test listeners through the subscribe callback the hook passes to useSyncExternalStore, so the test fails if the hook subscribes to the wrong substate. --- .../compiler/test_context_js_template.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index 6f787bb5ccb..c322b194a99 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -205,7 +205,11 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): const contextValues = new Map(); export const useContext = (context) => contextValues.get(context); export const useCallback = (fn) => fn; -export const useSyncExternalStore = (subscribe, getSnapshot) => getSnapshot(); +export let lastSubscribe; +export const useSyncExternalStore = (subscribe, getSnapshot) => { + lastSubscribe = subscribe; + return getSnapshot(); +}; export const useMemo = (fn) => fn(); export const useState = (initial) => { const index = hookIndex++; @@ -286,9 +290,14 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): let firstNotifications = 0; let secondNotifications = 0; let listNotifications = 0; -store.subscribe("reflex___state____state", () => firstNotifications++); -store.subscribe("reflex___state____state__sub", () => secondNotifications++); -store.subscribe("reflex___state____state__list", () => listNotifications++); +// Subscribe through the hook so the test covers the substate it subscribes to. +const subscribeVia = (name, listener) => { + registry.useStateContext(name); + return react.lastSubscribe(listener); +}; +subscribeVia("reflex___state____state", () => firstNotifications++); +subscribeVia("reflex___state____state__sub", () => secondNotifications++); +subscribeVia("reflex___state____state__list", () => listNotifications++); store.dispatchers["reflex___state____state__sub"]({ value: 2 }); store.dispatchers["reflex___state____state__sub"]({}); store.dispatchers["reflex___state____state__list"]({ items: ["a"] }); @@ -306,9 +315,9 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): }); const hmrBeforeSync = registry.useStateContext("reflex___state____state__new"); let hmrNotifications = 0; -store.subscribe("reflex___state____state__new", () => hmrNotifications++); +subscribeVia("reflex___state____state__new", () => hmrNotifications++); let existingStateNotifications = 0; -store.subscribe("reflex___state____state__sub", () => existingStateNotifications++); +subscribeVia("reflex___state____state__sub", () => existingStateNotifications++); store.syncInitialState(registry.app.initialState); const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); const existingStateAfterSync = registry.useStateContext("reflex___state____state__sub"); From 62cffada04dc96dbe91c122657f45e1d1f5fc3c1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 14 Oct 2026 22:30:47 +0500 Subject: [PATCH 19/20] refactor: seed the state store from its argument createStateStore takes the initial state and registers substates through one addSubstates path, which hot reload calls for the substates of new state classes. Fields added to existing substates are not merged here: they arrive with the hydrate delta after the backend restarts. --- .../src/reflex_base/compiler/templates.py | 48 +++++++------------ .../compiler/test_context_js_template.py | 8 ++-- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 49f4ce84396..242d6f49df9 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -525,49 +525,33 @@ def context_template( // Dispatch updates happen outside render; useSyncExternalStore keeps reads // consistent while notifying only consumers of the changed substate. -function createStateStore() {{ - const state = {{ ...initialState }}; +function createStateStore(initialState) {{ + const state = {{}}; const listeners = new Map(); const dispatchers = {{}}; - let syncedInitialState = initialState; - const registerSubstate = (substateName, initialValue) => {{ - if (!Object.hasOwn(state, substateName)) {{ - state[substateName] = initialValue; - }} - if (!Object.hasOwn(dispatchers, substateName)) {{ + const notify = (substateName) => + listeners.get(substateName)?.forEach((listener) => listener()); + // Hot reload adds the substates of new state classes here; new fields of + // existing substates arrive with the hydrate delta. + const addSubstates = (nextInitialState) => {{ + for (const [substateName, value] of Object.entries(nextInitialState)) {{ + if (Object.hasOwn(state, substateName)) continue; + state[substateName] = value; dispatchers[substateName] = (delta) => {{ const currentState = state[substateName]; const nextState = applyDelta(currentState, delta); if (nextState === currentState) return; state[substateName] = nextState; - listeners.get(substateName)?.forEach((listener) => listener()); + notify(substateName); }}; + notify(substateName); }} }}; - Object.entries(state).forEach(([substateName, value]) => - registerSubstate(substateName, value), - ); + addSubstates(initialState); return {{ dispatchers, - syncInitialState: (nextInitialState) => {{ - if (nextInitialState === syncedInitialState) return; - syncedInitialState = nextInitialState; - Object.entries(nextInitialState).forEach(([substateName, value]) => {{ - const isNewSubstate = !Object.hasOwn(state, substateName); - registerSubstate(substateName, value); - const currentState = state[substateName]; - const addedFields = Object.fromEntries( - Object.entries(value).filter(([key]) => !Object.hasOwn(currentState, key)), - ); - if (Object.keys(addedFields).length > 0) {{ - state[substateName] = {{ ...currentState, ...addedFields }}; - }} - if (isNewSubstate || Object.keys(addedFields).length > 0) {{ - listeners.get(substateName)?.forEach((listener) => listener()); - }} - }}); - }}, + addSubstates, getSnapshot: (substateName) => state[substateName], subscribe: (substateName, listener) => {{ let substateListeners = listeners.get(substateName); @@ -590,8 +574,8 @@ def context_template( }} function ClientStateProvider({{ children }}) {{ - const [store] = useState(createStateStore); - useEffect(() => store.syncInitialState(initialState), [store, initialState]); + const [store] = useState(() => createStateStore(initialState)); + useEffect(() => store.addSubstates(initialState), [store, initialState]); return createElement(StateStoreProviders, {{ children, store }}); }} diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index c322b194a99..f4381d53836 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -318,7 +318,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): subscribeVia("reflex___state____state__new", () => hmrNotifications++); let existingStateNotifications = 0; subscribeVia("reflex___state____state__sub", () => existingStateNotifications++); -store.syncInitialState(registry.app.initialState); +store.addSubstates(registry.app.initialState); const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); const existingStateAfterSync = registry.useStateContext("reflex___state____state__sub"); store.dispatchers["reflex___state____state__new"]({ value: 5 }); @@ -356,13 +356,13 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "hmrBeforeSync": {"value": 4}, "hmrAfterSync": {"value": 4}, "hmrUpdated": {"value": 5}, - "existingStateAfterSync": {"value": 3, "added": 9}, + "existingStateAfterSync": {"value": 3}, "hmrNotifications": 2, - "existingStateNotifications": 1, + "existingStateNotifications": 0, "noOpIdentityPreserved": True, "listNotifications": 0, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, "firstNotifications": 0, - "secondNotifications": 2, + "secondNotifications": 1, } From 3084bc4828cafc36ee8bfd1ec80306be5982e1e1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 15 Oct 2026 00:27:19 +0500 Subject: [PATCH 20/20] fix: merge new default fields into live substates on hot reload mergeInitialState adds new substates and gives existing substates the fields of the recompiled initial state at their defaults, keeping live values. A substate is replaced and notified only when it gains a field, so the mount-time call changes nothing. --- .../src/reflex_base/compiler/templates.py | 22 +++++++++++++------ .../compiler/test_context_js_template.py | 15 +++++++++---- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 242d6f49df9..4281685eab8 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -531,11 +531,19 @@ def context_template( const dispatchers = {{}}; const notify = (substateName) => listeners.get(substateName)?.forEach((listener) => listener()); - // Hot reload adds the substates of new state classes here; new fields of - // existing substates arrive with the hydrate delta. - const addSubstates = (nextInitialState) => {{ + // Hot reload passes the recompiled initial state: new substates are added, + // and existing substates gain new fields at their defaults while keeping + // their live values. + const mergeInitialState = (nextInitialState) => {{ for (const [substateName, value] of Object.entries(nextInitialState)) {{ - if (Object.hasOwn(state, substateName)) continue; + const liveState = state[substateName]; + if (liveState !== undefined) {{ + if (Object.keys(value).some((key) => !Object.hasOwn(liveState, key))) {{ + state[substateName] = {{ ...value, ...liveState }}; + notify(substateName); + }} + continue; + }} state[substateName] = value; dispatchers[substateName] = (delta) => {{ const currentState = state[substateName]; @@ -547,11 +555,11 @@ def context_template( notify(substateName); }} }}; - addSubstates(initialState); + mergeInitialState(initialState); return {{ dispatchers, - addSubstates, + mergeInitialState, getSnapshot: (substateName) => state[substateName], subscribe: (substateName, listener) => {{ let substateListeners = listeners.get(substateName); @@ -575,7 +583,7 @@ def context_template( function ClientStateProvider({{ children }}) {{ const [store] = useState(() => createStateStore(initialState)); - useEffect(() => store.addSubstates(initialState), [store, initialState]); + useEffect(() => store.mergeInitialState(initialState), [store, initialState]); return createElement(StateStoreProviders, {{ children, store }}); }} diff --git a/tests/units/compiler/test_context_js_template.py b/tests/units/compiler/test_context_js_template.py index f4381d53836..f0c6a9511c5 100644 --- a/tests/units/compiler/test_context_js_template.py +++ b/tests/units/compiler/test_context_js_template.py @@ -318,9 +318,14 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): subscribeVia("reflex___state____state__new", () => hmrNotifications++); let existingStateNotifications = 0; subscribeVia("reflex___state____state__sub", () => existingStateNotifications++); -store.addSubstates(registry.app.initialState); +store.mergeInitialState(registry.app.initialState); const hmrAfterSync = registry.useStateContext("reflex___state____state__new"); const existingStateAfterSync = registry.useStateContext("reflex___state____state__sub"); +// A second sync with the same initial state, as on mount, changes nothing. +const subBeforeRepeat = store.getSnapshot("reflex___state____state__sub"); +store.mergeInitialState(registry.app.initialState); +const repeatIdentityPreserved = + store.getSnapshot("reflex___state____state__sub") === subBeforeRepeat; store.dispatchers["reflex___state____state__new"]({ value: 5 }); const hmrUpdated = registry.useStateContext("reflex___state____state__new"); process.stdout.write(JSON.stringify({ @@ -333,6 +338,7 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): existingStateAfterSync, hmrNotifications, existingStateNotifications, + repeatIdentityPreserved, noOpIdentityPreserved, notificationsAfterNoOps, listNotifications, @@ -356,13 +362,14 @@ def test_client_state_store_notifies_only_substate_subscribers(tmp_path: Path): "hmrBeforeSync": {"value": 4}, "hmrAfterSync": {"value": 4}, "hmrUpdated": {"value": 5}, - "existingStateAfterSync": {"value": 3}, + "existingStateAfterSync": {"value": 3, "added": 9}, "hmrNotifications": 2, - "existingStateNotifications": 0, + "existingStateNotifications": 1, + "repeatIdentityPreserved": True, "noOpIdentityPreserved": True, "listNotifications": 0, "notificationsAfterNoOps": 0, "firstIdentityPreserved": True, "firstNotifications": 0, - "secondNotifications": 1, + "secondNotifications": 2, }