Skip to content

Commit d4c3dbc

Browse files
committed
fix use_context
components which are newly added to the layout after the initial render do not have access to contexts. we solve this by tracking life cycle hooks in stack and copying the context providers from parent to child hooks. we also change how contexts are implemented - instead of needing a create_context function which returns a new Context class, we just return a Context object that constructs a ContextProvider component. the earlier implementation was too clever and did not add anything for it.
1 parent 4500d55 commit d4c3dbc

File tree

11 files changed

+148
-120
lines changed

11 files changed

+148
-120
lines changed

src/idom/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .core.component import component
55
from .core.events import event
66
from .core.hooks import (
7+
Context,
78
create_context,
89
use_callback,
910
use_context,
@@ -27,6 +28,7 @@
2728
__all__ = [
2829
"component",
2930
"config",
31+
"Context",
3032
"create_context",
3133
"event",
3234
"hooks",

src/idom/backend/flask.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
logger = logging.getLogger(__name__)
3939

40-
ConnectionContext: type[Context[Connection | None]] = create_context(
40+
ConnectionContext: Context[Connection | None] = create_context(
4141
None, "ConnectionContext"
4242
)
4343

src/idom/backend/sanic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
logger = logging.getLogger(__name__)
3434

35-
ConnectionContext: type[Context[Connection | None]] = create_context(
35+
ConnectionContext: Context[Connection | None] = create_context(
3636
None, "ConnectionContext"
3737
)
3838

src/idom/backend/starlette.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@
3030

3131
logger = logging.getLogger(__name__)
3232

33-
WebSocketContext: type[Context[WebSocket | None]] = create_context(
34-
None, "WebSocketContext"
35-
)
33+
WebSocketContext: Context[WebSocket | None] = create_context(None, "WebSocketContext")
3634

3735

3836
def configure(

src/idom/backend/tornado.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path
2727

2828

29-
ConnectionContext: type[Context[Connection | None]] = create_context(
29+
ConnectionContext: Context[Connection | None] = create_context(
3030
None, "ConnectionContext"
3131
)
3232

src/idom/core/_f_back.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
from types import FrameType
5+
6+
7+
def f_module_name(index: int = 0) -> str:
8+
frame = f_back(index + 1)
9+
if frame is None:
10+
return ""
11+
name = frame.f_globals.get("__name__", "")
12+
assert isinstance(name, str), "Expected module name to be a string"
13+
return name
14+
15+
16+
def f_back(index: int = 0) -> FrameType | None:
17+
frame = inspect.currentframe()
18+
while frame is not None:
19+
if index < 0:
20+
return frame
21+
frame = frame.f_back
22+
index -= 1
23+
return None

src/idom/core/hooks.py

Lines changed: 79 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
Any,
99
Awaitable,
1010
Callable,
11-
ClassVar,
1211
Dict,
1312
Generic,
1413
List,
@@ -21,12 +20,14 @@
2120
cast,
2221
overload,
2322
)
23+
from warnings import warn
2424

2525
from typing_extensions import Protocol
2626

2727
from idom.config import IDOM_DEBUG_MODE
2828
from idom.utils import Ref
2929

30+
from ._f_back import f_module_name
3031
from ._thread_local import ThreadLocal
3132
from .types import ComponentType, Key, VdomDict
3233
from .vdom import vdom
@@ -240,107 +241,94 @@ def use_debug_value(
240241

241242

242243
def create_context(
243-
default_value: _StateType, name: str | None = None
244-
) -> type[Context[_StateType]]:
244+
default_value: _StateType, name: str = "Context"
245+
) -> Context[_StateType]:
245246
"""Return a new context type for use in :func:`use_context`"""
247+
warn(
248+
"The 'create_context' function is deprecated. "
249+
"Use the 'Context' class instead. "
250+
"This function will be removed in a future release."
251+
)
252+
return Context(name, default_value)
253+
246254

247-
class _Context(Context[_StateType]):
248-
_default_value = default_value
255+
_UNDEFINED = object()
249256

250-
_Context.__name__ = name or "Context"
251257

252-
return _Context
258+
class Context(Generic[_StateType]):
259+
"""Returns a :class:`ContextProvider` component"""
253260

261+
def __init__(self, name: str, default_value: _StateType) -> None:
262+
self.name = name
263+
self.default_value = default_value
254264

255-
def use_context(context_type: type[Context[_StateType]]) -> _StateType:
265+
def __call__(
266+
self,
267+
*children: Any,
268+
value: _StateType = _UNDEFINED,
269+
key: Key | None = None,
270+
) -> (
271+
# users don't need to see that this is a ContextProvider
272+
ComponentType
273+
):
274+
return ContextProvider(
275+
*children,
276+
value=self.default_value if value is _UNDEFINED else value,
277+
key=key,
278+
type=self,
279+
)
280+
281+
def __repr__(self):
282+
return f"{type(self).__name__}({self.name!r})"
283+
284+
285+
def use_context(context: Context[_StateType]) -> _StateType:
256286
"""Get the current value for the given context type.
257287
258288
See the full :ref:`Use Context` docs for more information.
259289
"""
260-
# We have to use a Ref here since, if initially context_type._current is None, and
261-
# then on a subsequent render it is present, we need to be able to dynamically adopt
262-
# that newly present current context. When we update it though, we don't need to
263-
# schedule a new render since we're already rending right now. Thus we can't do this
264-
# with use_state() since we'd incur an extra render when calling set_state.
265-
context_ref: Ref[Context[_StateType] | None] = use_ref(None)
266-
267-
if context_ref.current is None:
268-
provided_context = context_type._current.get()
269-
if provided_context is None:
270-
# Cast required because of: https://github.com/python/mypy/issues/5144
271-
return cast(_StateType, context_type._default_value)
272-
context_ref.current = provided_context
273-
274-
# We need the hook now so that we can schedule an update when
275290
hook = current_hook()
276291

277-
context = context_ref.current
292+
provider = hook.get_context_provider(context)
293+
if provider is None:
294+
return context.default_value
278295

279296
@use_effect
280297
def subscribe_to_context_change() -> Callable[[], None]:
281-
def set_context(new: Context[_StateType]) -> None:
282-
# We don't need to check if `new is not context_ref.current` because we only
283-
# trigger this callback when the value of a context, and thus the context
284-
# itself changes. Therefore we can always schedule a render.
285-
context_ref.current = new
286-
hook.schedule_render()
287-
288-
context.subscribers.add(set_context)
289-
return lambda: context.subscribers.remove(set_context)
290-
291-
return context.value
292-
298+
provider.subscribers.add(hook)
299+
return lambda: provider.subscribers.remove(hook)
293300

294-
_UNDEFINED: Any = object()
301+
return provider.value
295302

296303

297-
class Context(Generic[_StateType]):
298-
299-
# This should be _StateType instead of Any, but it can't due to this limitation:
300-
# https://github.com/python/mypy/issues/5144
301-
_default_value: ClassVar[Any]
302-
303-
_current: ClassVar[ThreadLocal[Context[Any] | None]]
304-
305-
def __init_subclass__(cls) -> None:
306-
# every context type tracks which of its instances are currently in use
307-
cls._current = ThreadLocal(lambda: None)
308-
304+
class ContextProvider(Generic[_StateType]):
309305
def __init__(
310306
self,
311307
*children: Any,
312-
value: _StateType = _UNDEFINED,
313-
key: Key | None = None,
308+
value: _StateType,
309+
key: Key | None,
310+
type: Context[_StateType],
314311
) -> None:
315312
self.children = children
316-
self.value: _StateType = self._default_value if value is _UNDEFINED else value
313+
self.value = value
317314
self.key = key
318-
self.subscribers: set[Callable[[Context[_StateType]], None]] = set()
319-
self.type = self.__class__
315+
self.subscribers: set[LifeCycleHook] = set()
316+
self.type = type
320317

321318
def render(self) -> VdomDict:
322-
current_ctx = self.__class__._current
323-
324-
prior_ctx = current_ctx.get()
325-
current_ctx.set(self)
326-
327-
def reset_ctx() -> None:
328-
current_ctx.set(prior_ctx)
329-
330-
current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, reset_ctx)
331-
319+
current_hook().set_context_provider(self)
332320
return vdom("", *self.children)
333321

334-
def should_render(self, new: Context[_StateType]) -> bool:
322+
def should_render(self, new: ContextProvider[_StateType]) -> bool:
335323
if self.value is not new.value:
336-
new.subscribers.update(self.subscribers)
337-
for set_context in self.subscribers:
338-
set_context(new)
324+
for hook in self.subscribers:
325+
hook.set_context_provider(new)
326+
hook.schedule_render()
339327
return True
340328
return False
341329

342330
def __repr__(self) -> str:
343-
return f"{type(self).__name__}({id(self)})"
331+
return f"{type(self).__name__}({self.type.name!r})"
344332

345333

346334
_ActionType = TypeVar("_ActionType")
@@ -558,14 +546,14 @@ def _try_to_infer_closure_values(
558546

559547
def current_hook() -> LifeCycleHook:
560548
"""Get the current :class:`LifeCycleHook`"""
561-
hook = _current_hook.get()
562-
if hook is None:
549+
hook_stack = _hook_stack.get()
550+
if not hook_stack:
563551
msg = "No life cycle hook is active. Are you rendering in a layout?"
564552
raise RuntimeError(msg)
565-
return hook
553+
return hook_stack[-1]
566554

567555

568-
_current_hook: ThreadLocal[LifeCycleHook | None] = ThreadLocal(lambda: None)
556+
_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
569557

570558

571559
EffectType = NewType("EffectType", str)
@@ -630,9 +618,8 @@ class LifeCycleHook:
630618
631619
hook.affect_component_did_render()
632620
633-
# This should only be called after any child components yielded by
634-
# component_instance.render() have also been rendered because effects of
635-
# this type must run after the full set of changes have been resolved.
621+
# This should only be called after the full set of changes associated with a
622+
# given render have been completed.
636623
hook.affect_layout_did_render()
637624
638625
# Typically an event occurs and a new render is scheduled, thus begining
@@ -650,6 +637,7 @@ class LifeCycleHook:
650637

651638
__slots__ = (
652639
"__weakref__",
640+
"_context_providers",
653641
"_current_state_index",
654642
"_event_effects",
655643
"_is_rendering",
@@ -666,6 +654,7 @@ def __init__(
666654
self,
667655
schedule_render: Callable[[], None],
668656
) -> None:
657+
self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
669658
self._schedule_render_callback = schedule_render
670659
self._schedule_render_later = False
671660
self._is_rendering = False
@@ -700,6 +689,14 @@ def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> N
700689
"""Trigger a function on the occurance of the given effect type"""
701690
self._event_effects[effect_type].append(function)
702691

692+
def set_context_provider(self, provider: ContextProvider[Any]) -> None:
693+
self._context_providers[provider.type] = provider
694+
695+
def get_context_provider(
696+
self, context: Context[_StateType]
697+
) -> ContextProvider[_StateType] | None:
698+
return self._context_providers.get(context)
699+
703700
def affect_component_will_render(self, component: ComponentType) -> None:
704701
"""The component is about to render"""
705702
self.component = component
@@ -753,13 +750,16 @@ def set_current(self) -> None:
753750
This method is called by a layout before entering the render method
754751
of this hook's associated component.
755752
"""
756-
_current_hook.set(self)
753+
hook_stack = _hook_stack.get()
754+
if hook_stack:
755+
parent = hook_stack[-1]
756+
self._context_providers.update(parent._context_providers)
757+
hook_stack.append(self)
757758

758759
def unset_current(self) -> None:
759760
"""Unset this hook as the active hook in this thread"""
760761
# this assertion should never fail - primarilly useful for debug
761-
assert _current_hook.get() is self
762-
_current_hook.set(None)
762+
assert _hook_stack.get().pop() is self
763763

764764
def _schedule_render(self) -> None:
765765
try:

0 commit comments

Comments
 (0)