|
10 | 10 | from reflex_base import constants |
11 | 11 | from reflex_base.constants import Hooks |
12 | 12 | 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 |
14 | 14 | from reflex_base.vars.base import VarData |
15 | 15 |
|
16 | 16 | if TYPE_CHECKING: |
@@ -343,13 +343,6 @@ def context_template( |
343 | 343 | initial_state = initial_state or {} |
344 | 344 | if initial_state_json is None: |
345 | 345 | 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 | | - |
353 | 346 | state_str = ( |
354 | 347 | rf""" |
355 | 348 | export const state_name = "{state_name}" |
@@ -416,11 +409,6 @@ def context_template( |
416 | 409 | """ |
417 | 410 | ) |
418 | 411 |
|
419 | | - substates_str = "".join( |
420 | | - f"\n ['{state_name}', '{format_state_name(state_name)}']," |
421 | | - for state_name in initial_state |
422 | | - ) |
423 | | - |
424 | 412 | disable_owner_stacks_str = ( |
425 | 413 | r""" |
426 | 414 | // Disable React dev-build owner-stack capture: the per-element Error() |
@@ -449,18 +437,17 @@ def context_template( |
449 | 437 | else "" |
450 | 438 | ) |
451 | 439 |
|
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" |
453 | 441 | 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" |
455 | 443 | import {{ jsx }} from "@emotion/react"; |
456 | 444 | {disable_owner_stacks_str} |
457 | | -export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext }}; |
| 445 | +export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }}; |
458 | 446 | export const initialState = {initial_state_json} |
459 | 447 | export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)} |
460 | 448 | export const stateSchema = {json.dumps(state_schema)} |
461 | 449 |
|
462 | 450 | export const defaultColorMode = {default_color_mode} |
463 | | -export const StateContexts = {{{state_contexts_str}}}; |
464 | 451 | export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} |
465 | 452 |
|
466 | 453 | {state_str} |
@@ -543,78 +530,80 @@ def context_template( |
543 | 530 | ); |
544 | 531 | }} |
545 | 532 |
|
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); |
563 | 566 |
|
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 | +}} |
587 | 582 |
|
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 | +}} |
591 | 590 |
|
592 | 591 | 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 }}); |
601 | 595 | }} |
602 | 596 |
|
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 | +
|
607 | 605 | 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 }}); |
618 | 607 | }} |
619 | 608 |
|
620 | 609 | export const StateProvider = |
|
0 commit comments