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/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/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/src/reflex_base/.templates/web/utils/context-registry.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/context-registry.js index bfd4e105f67..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 @@ -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, + useContext, + useMemo, + useSyncExternalStore, +} from "react"; export const ColorModeContext = createContext({ rawColorMode: "system", @@ -15,31 +20,30 @@ 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. - * - * The first call for a name creates the context; later calls, including ones - * made from a re-executed generated module, return the same object. + * 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, getSnapshot] = useMemo( + () => [ + (listener) => store.subscribe(name, listener), + () => store.getSnapshot(name) ?? app.initialState?.[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 021988199da..4281685eab8 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,13 +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}" @@ -411,11 +404,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,17 +432,16 @@ 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, 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)} 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} @@ -536,78 +523,80 @@ 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 ``SubstateProvider`` -// 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(initialState) {{ + const state = {{}}; + const listeners = new Map(); + const dispatchers = {{}}; + const notify = (substateName) => + listeners.get(substateName)?.forEach((listener) => listener()); + // 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)) {{ + 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]; + const nextState = applyDelta(currentState, delta); + if (nextState === currentState) return; + state[substateName] = nextState; + notify(substateName); + }}; + notify(substateName); + }} + }}; + mergeInitialState(initialState); -// 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 }}) => {{ - const dispatchers = useContext(DispatchContext); - const [state, dispatchSubstate] = useReducer( - applyDelta, - initialState[substateName], - ); - // 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 skipped with a warning. - useIsomorphicLayoutEffect(() => {{ - dispatchers[substateName] = dispatchSubstate; - return () => {{ - delete dispatchers[substateName]; - }}; - }}, [dispatchers, dispatchSubstate, substateName]); - return useMemo( - () => createElement(StateContexts[contextName], {{ value: state }}, children), - [children, state, contextName], - ); -}}; + return {{ + dispatchers, + mergeInitialState, + getSnapshot: (substateName) => state[substateName], + subscribe: (substateName, listener) => {{ + let substateListeners = listeners.get(substateName); + if (!substateListeners) {{ + substateListeners = new Set(); + listeners.set(substateName, substateListeners); + }} + substateListeners.add(listener); + return () => substateListeners.delete(listener); + }}, + }}; +}} -// ``[substateName, contextName]`` for every substate, outermost first. -const SUBSTATES = [{substates_str} -]; +function StateStoreProviders({{ children, store }}) {{ + return createElement( + StateStoreContext, + {{ value: store }}, + createElement(DispatchContext, {{ value: store.dispatchers }}, children), + ); +}} 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); - }} - return createElement(DispatchProvider, {{}}, tree); - }}, [children]); + const [store] = useState(() => createStateStore(initialState)); + useEffect(() => store.mergeInitialState(initialState), [store, initialState]); + return createElement(StateStoreProviders, {{ children, store }}); }} -// 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. +// 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 }}) {{ - 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: serverStateStore }}); }} export const StateProvider = 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 15323e5e27b..0671d80c917 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -636,19 +636,19 @@ 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")], + f"$/{constants.Dirs.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/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/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 254984188c0..bb460e3d99e 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2951,9 +2951,9 @@ 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 "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 b644a73f842..4e02b372b9b 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1742,76 +1742,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 provider 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. - """ - 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 reducers live in SubstateProvider; the client provider only composes. - 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 - - -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" ? ServerStateProvider : ClientStateProvider;' - ) - - 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_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 16c181bc0a6..f0c6a9511c5 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,16 @@ export const UploadFilesContext = {}; export const DispatchContext = {}; export const EventLoopContext = {}; -export const getStateContext = () => ({}); +export const StateStoreContext = { name: "StateStoreContext" }; +StateStoreContext.Provider = StateStoreContext; +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 +73,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 +106,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 +147,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,13 +168,208 @@ 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_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"', + 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") + react_stub = """ +let hookIndex = 0; +let hookStates = []; +const contextValues = new Map(); +export const useContext = (context) => contextValues.get(context); +export const useCallback = (fn) => fn; +export let lastSubscribe; +export const useSyncExternalStore = (subscribe, getSnapshot) => { + lastSubscribe = subscribe; + return getSnapshot(); +}; +export const useMemo = (fn) => fn(); +export const useState = (initial) => { + const index = hookIndex++; + hookStates[index] ??= typeof initial === "function" ? initial() : initial; + return [hookStates[index], () => {}]; +}; +export const useEffect = () => {}; +export const createContext = (defaultValue) => { + const context = { defaultValue }; + context.Provider = context; + return context; +}; +export const createElement = (type, props, ...children) => ({ + type, + props: { + ...props, + children: children.length + ? children.length === 1 ? children[0] : children + : props?.children, + }, +}); +export const setContextValue = (context, value) => contextValues.set(context, value); +""" + state_stub = """ +// 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 = () => []; +export const refs = {}; +""" + 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", registry_source), + ("@emotion/react", "export const jsx = () => null;\n"), + ): + 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" + module.write_text(rendered) + driver = tmp_path / "driver.mjs" + 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 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; +let listNotifications = 0; +// 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"] }); +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"); +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; +subscribeVia("reflex___state____state__new", () => hmrNotifications++); +let existingStateNotifications = 0; +subscribeVia("reflex___state____state__sub", () => existingStateNotifications++); +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({ + first, + second, + updated, + hmrBeforeSync, + hmrAfterSync, + hmrUpdated, + existingStateAfterSync, + hmrNotifications, + existingStateNotifications, + repeatIdentityPreserved, + noOpIdentityPreserved, + notificationsAfterNoOps, + listNotifications, + firstIdentityPreserved: store.getSnapshot("reflex___state____state") === first, + firstNotifications, + secondNotifications, +})); +""" + ) + + result = subprocess.run( + ["node", str(driver)], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "first": {"value": 1}, + "second": {"value": 2}, + "updated": {"value": 3}, + "hmrBeforeSync": {"value": 4}, + "hmrAfterSync": {"value": 4}, + "hmrUpdated": {"value": 5}, + "existingStateAfterSync": {"value": 3, "added": 9}, + "hmrNotifications": 2, + "existingStateNotifications": 1, + "repeatIdentityPreserved": True, + "noOpIdentityPreserved": True, + "listNotifications": 0, + "notificationsAfterNoOps": 0, + "firstIdentityPreserved": True, + "firstNotifications": 0, + "secondNotifications": 2, + } diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 43593795ce3..2182953b4d7 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( @@ -2052,7 +2052,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]}" @@ -2224,7 +2224,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. """ @@ -2240,12 +2240,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]}" @@ -2417,7 +2417,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( @@ -2650,7 +2650,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 1959b77e7a0..dcba084aa39 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -2171,9 +2171,13 @@ 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 page._get_all_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/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(): diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 437294685a3..be907301475 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}") ) diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 5838cace45d..fe0639d53ac 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") @@ -1587,6 +1587,17 @@ def test_retrieval(): ) +@pytest.mark.parametrize( + ("javascript_code", "expected"), + [ + ("const state = useStateContext('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")