Skip to content

Commit 7bebc4d

Browse files
authored
Track contexts in hooks as state (#787)
* 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. * add test * add changelog entry * fix mypy * fix tornado dev server * remove unused func * improve test * remove unused method * fix style issues * get clever with functions * fix tests * update changelog * get coverage
1 parent 4500d55 commit 7bebc4d

File tree

12 files changed

+184
-145
lines changed

12 files changed

+184
-145
lines changed

docs/source/about/changelog.rst

+8-2
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@ more info, see the :ref:`Contributor Guide <Creating a Changelog Entry>`.
2323
Unreleased
2424
----------
2525

26-
**Added**
26+
**Fixed**
2727

28-
- :pull:`123` - ``asgiref`` as a dependency
28+
- :issue:`789` - Conditionally rendered components cannot use contexts
2929

3030
**Changed**
3131

3232
- :pull:`123` - set default timeout on playwright page for testing
33+
- :pull:`787` - Track contexts in hooks as state
34+
- :pull:`787` - remove non-standard ``name`` argument from ``create_context``
35+
36+
**Added**
37+
38+
- :pull:`123` - ``asgiref`` as a dependency
3339

3440

3541
v0.39.0

src/idom/backend/flask.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@
3737

3838
logger = logging.getLogger(__name__)
3939

40-
ConnectionContext: type[Context[Connection | None]] = create_context(
41-
None, "ConnectionContext"
42-
)
40+
ConnectionContext: Context[Connection | None] = create_context(None)
4341

4442

4543
def configure(

src/idom/backend/sanic.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@
3232

3333
logger = logging.getLogger(__name__)
3434

35-
ConnectionContext: type[Context[Connection | None]] = create_context(
36-
None, "ConnectionContext"
37-
)
35+
ConnectionContext: Context[Connection | None] = create_context(None)
3836

3937

4038
def configure(

src/idom/backend/starlette.py

+1-3
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)
3634

3735

3836
def configure(

src/idom/backend/tornado.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path
2727

2828

29-
ConnectionContext: type[Context[Connection | None]] = create_context(
30-
None, "ConnectionContext"
31-
)
29+
ConnectionContext: Context[Connection | None] = create_context(None)
3230

3331

3432
def configure(
@@ -67,8 +65,7 @@ async def serve_development_app(
6765
) -> None:
6866
enable_pretty_logging()
6967

70-
# setup up tornado to use asyncio
71-
AsyncIOMainLoop().install()
68+
AsyncIOMainLoop.current().install()
7269

7370
server = HTTPServer(app)
7471
server.listen(port, host)

src/idom/core/_f_back.py

+23
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 "" # pragma: no cover
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 # pragma: no cover

src/idom/core/_thread_local.py

-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,3 @@ def get(self) -> _StateType:
2020
else:
2121
state = self._state[thread]
2222
return state
23-
24-
def set(self, state: _StateType) -> None:
25-
self._state[current_thread()] = state

src/idom/core/hooks.py

+78-81
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,
@@ -239,108 +238,94 @@ def use_debug_value(
239238
logger.debug(f"{current_hook().component} {new}")
240239

241240

242-
def create_context(
243-
default_value: _StateType, name: str | None = None
244-
) -> type[Context[_StateType]]:
241+
def create_context(default_value: _StateType) -> Context[_StateType]:
245242
"""Return a new context type for use in :func:`use_context`"""
246243

247-
class _Context(Context[_StateType]):
248-
_default_value = default_value
244+
def context(
245+
*children: Any,
246+
value: _StateType = default_value,
247+
key: Key | None = None,
248+
) -> ContextProvider[_StateType]:
249+
return ContextProvider(
250+
*children,
251+
value=value,
252+
key=key,
253+
type=context,
254+
)
255+
256+
context.__qualname__ = "context"
257+
258+
return context
249259

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

252-
return _Context
261+
class Context(Protocol[_StateType]):
262+
"""Returns a :class:`ContextProvider` component"""
253263

264+
def __call__(
265+
self,
266+
*children: Any,
267+
value: _StateType = ...,
268+
key: Key | None = ...,
269+
) -> ContextProvider[_StateType]:
270+
...
254271

255-
def use_context(context_type: type[Context[_StateType]]) -> _StateType:
272+
273+
def use_context(context: Context[_StateType]) -> _StateType:
256274
"""Get the current value for the given context type.
257275
258276
See the full :ref:`Use Context` docs for more information.
259277
"""
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
275278
hook = current_hook()
279+
provider = hook.get_context_provider(context)
280+
281+
if provider is None:
282+
# force type checker to realize this is just a normal function
283+
assert isinstance(context, FunctionType), f"{context} is not a Context"
284+
# __kwdefault__ can be None if no kwarg only parameters exist
285+
assert context.__kwdefaults__ is not None, f"{context} has no 'value' kwarg"
286+
# lastly check that 'value' kwarg exists
287+
assert "value" in context.__kwdefaults__, f"{context} has no 'value' kwarg"
288+
# then we can safely access the context's default value
289+
return cast(_StateType, context.__kwdefaults__["value"])
276290

277-
context = context_ref.current
291+
subscribers = provider._subscribers
278292

279293
@use_effect
280294
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-
295+
subscribers.add(hook)
296+
return lambda: subscribers.remove(hook)
293297

294-
_UNDEFINED: Any = object()
298+
return provider._value
295299

296300

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-
301+
class ContextProvider(Generic[_StateType]):
309302
def __init__(
310303
self,
311304
*children: Any,
312-
value: _StateType = _UNDEFINED,
313-
key: Key | None = None,
305+
value: _StateType,
306+
key: Key | None,
307+
type: Context[_StateType],
314308
) -> None:
315309
self.children = children
316-
self.value: _StateType = self._default_value if value is _UNDEFINED else value
317310
self.key = key
318-
self.subscribers: set[Callable[[Context[_StateType]], None]] = set()
319-
self.type = self.__class__
311+
self.type = type
312+
self._subscribers: set[LifeCycleHook] = set()
313+
self._value = value
320314

321315
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-
316+
current_hook().set_context_provider(self)
332317
return vdom("", *self.children)
333318

334-
def should_render(self, new: Context[_StateType]) -> bool:
335-
if self.value is not new.value:
336-
new.subscribers.update(self.subscribers)
337-
for set_context in self.subscribers:
338-
set_context(new)
319+
def should_render(self, new: ContextProvider[_StateType]) -> bool:
320+
if self._value is not new._value:
321+
for hook in self._subscribers:
322+
hook.set_context_provider(new)
323+
hook.schedule_render()
339324
return True
340325
return False
341326

342327
def __repr__(self) -> str:
343-
return f"{type(self).__name__}({id(self)})"
328+
return f"{type(self).__name__}({self.type})"
344329

345330

346331
_ActionType = TypeVar("_ActionType")
@@ -558,14 +543,14 @@ def _try_to_infer_closure_values(
558543

559544
def current_hook() -> LifeCycleHook:
560545
"""Get the current :class:`LifeCycleHook`"""
561-
hook = _current_hook.get()
562-
if hook is None:
546+
hook_stack = _hook_stack.get()
547+
if not hook_stack:
563548
msg = "No life cycle hook is active. Are you rendering in a layout?"
564549
raise RuntimeError(msg)
565-
return hook
550+
return hook_stack[-1]
566551

567552

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

570555

571556
EffectType = NewType("EffectType", str)
@@ -630,9 +615,8 @@ class LifeCycleHook:
630615
631616
hook.affect_component_did_render()
632617
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.
618+
# This should only be called after the full set of changes associated with a
619+
# given render have been completed.
636620
hook.affect_layout_did_render()
637621
638622
# Typically an event occurs and a new render is scheduled, thus begining
@@ -650,6 +634,7 @@ class LifeCycleHook:
650634

651635
__slots__ = (
652636
"__weakref__",
637+
"_context_providers",
653638
"_current_state_index",
654639
"_event_effects",
655640
"_is_rendering",
@@ -666,6 +651,7 @@ def __init__(
666651
self,
667652
schedule_render: Callable[[], None],
668653
) -> None:
654+
self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
669655
self._schedule_render_callback = schedule_render
670656
self._schedule_render_later = False
671657
self._is_rendering = False
@@ -700,6 +686,14 @@ def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> N
700686
"""Trigger a function on the occurance of the given effect type"""
701687
self._event_effects[effect_type].append(function)
702688

689+
def set_context_provider(self, provider: ContextProvider[Any]) -> None:
690+
self._context_providers[provider.type] = provider
691+
692+
def get_context_provider(
693+
self, context: Context[_StateType]
694+
) -> ContextProvider[_StateType] | None:
695+
return self._context_providers.get(context)
696+
703697
def affect_component_will_render(self, component: ComponentType) -> None:
704698
"""The component is about to render"""
705699
self.component = component
@@ -753,13 +747,16 @@ def set_current(self) -> None:
753747
This method is called by a layout before entering the render method
754748
of this hook's associated component.
755749
"""
756-
_current_hook.set(self)
750+
hook_stack = _hook_stack.get()
751+
if hook_stack:
752+
parent = hook_stack[-1]
753+
self._context_providers.update(parent._context_providers)
754+
hook_stack.append(self)
757755

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

764761
def _schedule_render(self) -> None:
765762
try:

0 commit comments

Comments
 (0)