Skip to content

Commit 81f463b

Browse files
harsh21234iCopilotmasenfFarhanAliRaza
authored
fix: flatten providers for large state trees (#7441)
* fix: reduce client state provider nesting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: avoid copying all client substates per delta Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: flatten large state provider trees * fix: address state provider review feedback * docs: add PR 7441 performance news fragment * docs: add reflex package performance news fragment * docs: use valid orphan news fragment name * fix: preserve legacy state context reads * fix: keep legacy state context reads compatible * Fix compiler pre-commit checks for legacy context rewrite * Install greenlet in legacy playground check * Address PR 7441 review feedback * Fix remaining PR 7441 review findings * Fix legacy state read scanning and rewriting * 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. * chore: drop breaking fragment for internal StateContexts removal * 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. * 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. * 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. * 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. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Masen Furer <m_github@0x26.net> Co-authored-by: Farhan <www.mfarvirus@gmail.com>
1 parent 7a1de65 commit 81f463b

17 files changed

Lines changed: 372 additions & 223 deletions

File tree

‎docs/app/tests/frontend_quality.test.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_
147147
bundle_library('quality-lazy-fixture')
148148
print(compile_app_root(rx.el.div('Quality'))[1])
149149
`], {cwd: root, encoding: "utf8"});
150-
const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_state", "loadFixture",
150+
const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_context_registry", "utils_state", "loadFixture",
151151
rootCode.replace(/^import .*$/gm, "")
152152
.replace(/^export default function/gm, "function")
153153
.replace(/^export /gm, "")
@@ -165,7 +165,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_
165165
try {
166166
let calls = 0;
167167
globalThis.window = {};
168-
setup(window, react, {}, {}, {}, async () => {calls++; return {value: 42};});
168+
setup(window, react, {}, {}, {}, {}, async () => {calls++; return {value: 42};});
169169
assert.equal(calls, 0);
170170
assert.equal(window.__reflex.react, react);
171171
assert.equal(window.__reflex["quality-lazy-fixture"], undefined);
@@ -179,7 +179,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_
179179

180180
let attempts = 0;
181181
globalThis.window = {};
182-
setup(window, react, {}, {}, {}, async () => {
182+
setup(window, react, {}, {}, {}, {}, async () => {
183183
if (++attempts === 1) throw new Error("Temporary download failure");
184184
return {value: 43};
185185
});

‎news/7441.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Detect state reads from the new shared state store when checking that an app that reads state defines an `rx.State` subclass.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
// a module the compiler rewrites: Vite re-executes a rewritten module on hot
55
// update, and every module it imports along with it, but this module imports
66
// nothing that can change and is never re-executed.
7-
import { createContext } from "react";
7+
import {
8+
createContext,
9+
useContext,
10+
useMemo,
11+
useSyncExternalStore,
12+
} from "react";
813

914
export const ColorModeContext = createContext({
1015
rawColorMode: "system",
@@ -15,31 +20,30 @@ export const ColorModeContext = createContext({
1520
export const UploadFilesContext = createContext(null);
1621
export const DispatchContext = createContext(null);
1722
export const EventLoopContext = createContext(null);
23+
export const StateStoreContext = createContext(null);
1824

1925
ColorModeContext.displayName = "ColorModeContext";
2026
UploadFilesContext.displayName = "UploadFilesContext";
2127
DispatchContext.displayName = "DispatchContext";
2228
EventLoopContext.displayName = "EventLoopContext";
23-
24-
const stateContexts = new Map();
29+
StateStoreContext.displayName = "StateStoreContext";
2530

2631
/**
27-
* Return the context object carrying the named Python state.
28-
*
29-
* The first call for a name creates the context; later calls, including ones
30-
* made from a re-executed generated module, return the same object.
32+
* Subscribe a component to one Python substate in the shared store.
3133
*
3234
* @param {string} name - The dotted Python state name.
33-
* @returns {React.Context} The state's context object.
35+
* @returns {object} The current state value for the named substate.
3436
*/
35-
export function getStateContext(name) {
36-
let context = stateContexts.get(name);
37-
if (context === undefined) {
38-
context = createContext(null);
39-
context.displayName = `StateContext(${name})`;
40-
stateContexts.set(name, context);
41-
}
42-
return context;
37+
export function useStateContext(name) {
38+
const store = useContext(StateStoreContext);
39+
const [subscribe, getSnapshot] = useMemo(
40+
() => [
41+
(listener) => store.subscribe(name, listener),
42+
() => store.getSnapshot(name) ?? app.initialState?.[name],
43+
],
44+
[store, name],
45+
);
46+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
4347
}
4448

4549
// Values the generated context module registers each time it executes. The

‎packages/reflex-base/src/reflex_base/compiler/templates.py‎

Lines changed: 71 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from reflex_base import constants
1111
from reflex_base.constants import Hooks
1212
from reflex_base.utils import memo_paths
13-
from reflex_base.utils.format import format_state_name, json_dumps
13+
from reflex_base.utils.format import json_dumps
1414
from reflex_base.vars.base import VarData
1515

1616
if TYPE_CHECKING:
@@ -343,13 +343,6 @@ def context_template(
343343
initial_state = initial_state or {}
344344
if initial_state_json is None:
345345
initial_state_json = json_dumps(initial_state)
346-
# Context objects come from the static registry so they survive hot
347-
# updates of this module; only the lookup table is generated here.
348-
state_contexts_str = "".join([
349-
f'{format_state_name(state_name)}: getStateContext("{state_name}"),'
350-
for state_name in initial_state
351-
])
352-
353346
state_str = (
354347
rf"""
355348
export const state_name = "{state_name}"
@@ -416,11 +409,6 @@ def context_template(
416409
"""
417410
)
418411

419-
substates_str = "".join(
420-
f"\n ['{state_name}', '{format_state_name(state_name)}'],"
421-
for state_name in initial_state
422-
)
423-
424412
disable_owner_stacks_str = (
425413
r"""
426414
// Disable React dev-build owner-stack capture: the per-element Error()
@@ -449,18 +437,17 @@ def context_template(
449437
else ""
450438
)
451439

452-
return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useReducer, useRef, useState, createElement, useEffect, useLayoutEffect }} from "react"
440+
return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react"
453441
import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state"
454-
import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, getStateContext, registerApp, eventLoop }} from "$/utils/context-registry"
442+
import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, registerApp, eventLoop }} from "$/utils/context-registry"
455443
import {{ jsx }} from "@emotion/react";
456444
{disable_owner_stacks_str}
457-
export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext }};
445+
export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }};
458446
export const initialState = {initial_state_json}
459447
export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)}
460448
export const stateSchema = {json.dumps(state_schema)}
461449
462450
export const defaultColorMode = {default_color_mode}
463-
export const StateContexts = {{{state_contexts_str}}};
464451
export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)}
465452
466453
{state_str}
@@ -543,78 +530,80 @@ def context_template(
543530
);
544531
}}
545532
546-
// ``useLayoutEffect`` warns when rendered on the server, where no effect runs
547-
// at all, so fall back to ``useEffect`` there.
548-
const useIsomorphicLayoutEffect =
549-
typeof document !== "undefined" ? useLayoutEffect : useEffect;
550-
551-
// Holds the mutable substate -> dispatch registry that ``SubstateProvider``
552-
// writes into and ``EventLoopProvider`` reads. The registry object identity is
553-
// stable for the lifetime of the tree, so neither adding a dispatcher nor
554-
// updating a substate re-renders the consumers of ``DispatchContext``.
555-
const DispatchProvider = ({{ children }}) => {{
556-
const dispatchers = useRef({{}});
557-
return useMemo(
558-
() =>
559-
createElement(DispatchContext, {{ value: dispatchers.current }}, children),
560-
[children],
561-
);
562-
}};
533+
// Dispatch updates happen outside render; useSyncExternalStore keeps reads
534+
// consistent while notifying only consumers of the changed substate.
535+
function createStateStore(initialState) {{
536+
const state = {{}};
537+
const listeners = new Map();
538+
const dispatchers = {{}};
539+
const notify = (substateName) =>
540+
listeners.get(substateName)?.forEach((listener) => listener());
541+
// Hot reload passes the recompiled initial state: new substates are added,
542+
// and existing substates gain new fields at their defaults while keeping
543+
// their live values.
544+
const mergeInitialState = (nextInitialState) => {{
545+
for (const [substateName, value] of Object.entries(nextInitialState)) {{
546+
const liveState = state[substateName];
547+
if (liveState !== undefined) {{
548+
if (Object.keys(value).some((key) => !Object.hasOwn(liveState, key))) {{
549+
state[substateName] = {{ ...value, ...liveState }};
550+
notify(substateName);
551+
}}
552+
continue;
553+
}}
554+
state[substateName] = value;
555+
dispatchers[substateName] = (delta) => {{
556+
const currentState = state[substateName];
557+
const nextState = applyDelta(currentState, delta);
558+
if (nextState === currentState) return;
559+
state[substateName] = nextState;
560+
notify(substateName);
561+
}};
562+
notify(substateName);
563+
}}
564+
}};
565+
mergeInitialState(initialState);
563566
564-
// One provider per substate: each owns its own reducer, so a delta for one
565-
// substate only re-renders its provider instead of recreating every provider.
566-
const SubstateProvider = ({{ children, substateName, contextName }}) => {{
567-
const dispatchers = useContext(DispatchContext);
568-
const [state, dispatchSubstate] = useReducer(
569-
applyDelta,
570-
initialState[substateName],
571-
);
572-
// A layout effect, not a passive one: layout effects for the whole commit
573-
// run before any passive effect, so every dispatcher is registered before
574-
// ``EventLoopProvider`` (mounted below this provider) connects the socket.
575-
// A delta naming an unregistered substate is skipped with a warning.
576-
useIsomorphicLayoutEffect(() => {{
577-
dispatchers[substateName] = dispatchSubstate;
578-
return () => {{
579-
delete dispatchers[substateName];
580-
}};
581-
}}, [dispatchers, dispatchSubstate, substateName]);
582-
return useMemo(
583-
() => createElement(StateContexts[contextName], {{ value: state }}, children),
584-
[children, state, contextName],
585-
);
586-
}};
567+
return {{
568+
dispatchers,
569+
mergeInitialState,
570+
getSnapshot: (substateName) => state[substateName],
571+
subscribe: (substateName, listener) => {{
572+
let substateListeners = listeners.get(substateName);
573+
if (!substateListeners) {{
574+
substateListeners = new Set();
575+
listeners.set(substateName, substateListeners);
576+
}}
577+
substateListeners.add(listener);
578+
return () => substateListeners.delete(listener);
579+
}},
580+
}};
581+
}}
587582
588-
// ``[substateName, contextName]`` for every substate, outermost first.
589-
const SUBSTATES = [{substates_str}
590-
];
583+
function StateStoreProviders({{ children, store }}) {{
584+
return createElement(
585+
StateStoreContext,
586+
{{ value: store }},
587+
createElement(DispatchContext, {{ value: store.dispatchers }}, children),
588+
);
589+
}}
591590
592591
function ClientStateProvider({{ children }}) {{
593-
return useMemo(() => {{
594-
let tree = children;
595-
for (let i = SUBSTATES.length - 1; i >= 0; i--) {{
596-
const [substateName, contextName] = SUBSTATES[i];
597-
tree = createElement(SubstateProvider, {{ substateName, contextName }}, tree);
598-
}}
599-
return createElement(DispatchProvider, {{}}, tree);
600-
}}, [children]);
592+
const [store] = useState(() => createStateStore(initialState));
593+
useEffect(() => store.mergeInitialState(initialState), [store, initialState]);
594+
return createElement(StateStoreProviders, {{ children, store }});
601595
}}
602596
603-
// The server renders once and never applies a delta, so it provides the
604-
// initial state through bare context providers. ``SubstateProvider`` would add
605-
// a second render level per substate, and the server renderer recurses once per
606-
// level, so with many substates rendering a page can exhaust the stack.
597+
// The server renders once and never applies a delta, so it only reads the
598+
// initial state.
599+
const serverStateStore = {{
600+
dispatchers: {{}},
601+
getSnapshot: (substateName) => initialState[substateName],
602+
subscribe: () => () => {{}},
603+
}};
604+
607605
function ServerStateProvider({{ children }}) {{
608-
let tree = children;
609-
for (let i = SUBSTATES.length - 1; i >= 0; i--) {{
610-
const [substateName, contextName] = SUBSTATES[i];
611-
tree = createElement(
612-
StateContexts[contextName],
613-
{{ value: initialState[substateName] }},
614-
tree,
615-
);
616-
}}
617-
return createElement(DispatchContext, {{ value: {{}} }}, tree);
606+
return createElement(StateStoreProviders, {{ children, store: serverStateStore }});
618607
}}
619608
620609
export const StateProvider =

‎packages/reflex-base/src/reflex_base/registry.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def _default_bundled_libraries() -> list[str]:
3232
"react",
3333
"@emotion/react",
3434
f"$/{constants.Dirs.CONTEXTS_PATH}",
35+
f"$/{constants.Dirs.UTILS}/context-registry",
3536
f"$/{constants.Dirs.STATE_PATH}",
3637
]
3738

‎packages/reflex-base/src/reflex_base/vars/base.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -636,19 +636,19 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa
636636
from reflex_base.utils import format
637637

638638
state_name = state if isinstance(state, str) else state.get_full_name()
639+
state_var_name = format.format_state_name(state_name)
639640
return VarData(
640641
state=state_name,
641642
field_name=field_name,
642643
hooks={
643-
"const {0} = useContext(StateContexts.{0})".format(
644-
format.format_state_name(state_name)
645-
): None
644+
f"const {state_var_name} = useStateContext({json.dumps(state_name)})": None
646645
},
647646
imports={
648-
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
649-
"react": [ImportVar(tag="useContext")],
647+
f"$/{constants.Dirs.UTILS}/context-registry": [
648+
ImportVar(tag="useStateContext")
649+
],
650650
},
651-
# State Vars read ``StateContexts``/``EventLoopContext``, so the
651+
# State Vars read the shared store/``EventLoopContext``, so the
652652
# providers must enclose every component that uses them.
653653
app_wraps=get_event_app_wraps(),
654654
)

‎reflex/compiler/utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ def compile_experimental_component_memo(
528528
)
529529
render._imports_cache = analysis.imports
530530
# Strings returned by the root's ``add_hooks`` can reference symbols
531-
# (``refs``, ``StateContexts``, etc.) that normally reach this module
531+
# (``refs``, ``EventLoopContext``, etc.) that normally reach this module
532532
# through descendants' ``_get_hooks_imports`` / ``_get_imports``. JS
533533
# imports are side-effect-free and dedup cleanly, so pulling the
534534
# whole subtree's imports here is safe even when some go unused.

‎reflex/state.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3024,9 +3024,9 @@ def code_uses_state_contexts(javascript_code: str) -> bool:
30243024
javascript_code: The Javascript code to check.
30253025
30263026
Returns:
3027-
True if the code attempts to access a member of StateContexts.
3027+
True if the code subscribes to a state context.
30283028
"""
3029-
return bool("useContext(StateContexts" in javascript_code)
3029+
return "useStateContext(" in javascript_code
30303030

30313031

30323032
def reload_state_module(

0 commit comments

Comments
 (0)