pulse-framework 0.1.39__py3-none-any.whl → 0.1.41__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pulse/decorators.py CHANGED
@@ -1,10 +1,17 @@
1
1
  # Separate file from reactive.py due to needing to import from state too
2
2
 
3
3
  import inspect
4
- from collections.abc import Callable, Coroutine
5
- from typing import Any, Protocol, TypeVar, overload
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, overload
6
6
 
7
- from pulse.query import QueryProperty, QueryPropertyWithInitial
7
+ from pulse.helpers import MISSING
8
+ from pulse.queries.common import OnErrorFn, OnSuccessFn
9
+ from pulse.queries.mutation import MutationProperty
10
+ from pulse.queries.query import RETRY_DELAY_DEFAULT
11
+ from pulse.queries.query_observer import (
12
+ QueryProperty,
13
+ QueryPropertyWithInitial,
14
+ )
8
15
  from pulse.reactive import (
9
16
  AsyncEffect,
10
17
  AsyncEffectFn,
@@ -18,6 +25,7 @@ from pulse.state import ComputedProperty, State, StateEffect
18
25
 
19
26
  T = TypeVar("T")
20
27
  TState = TypeVar("TState", bound=State)
28
+ P = ParamSpec("P")
21
29
 
22
30
 
23
31
  # -> @ps.computed The chalenge is:
@@ -58,7 +66,7 @@ def computed(fn: Callable[..., Any] | None = None, *, name: str | None = None):
58
66
 
59
67
 
60
68
  StateEffectFn = Callable[[TState], EffectCleanup | None]
61
- AsyncStateEffectFn = Callable[[TState], Coroutine[Any, Any, EffectCleanup | None]]
69
+ AsyncStateEffectFn = Callable[[TState], Awaitable[EffectCleanup | None]]
62
70
 
63
71
 
64
72
  class EffectBuilder(Protocol):
@@ -171,75 +179,159 @@ def effect(
171
179
  # -----------------
172
180
  # Query decorator
173
181
  # -----------------
182
+
183
+
184
+ # With initial (narrowed return type) - more specific overloads first
174
185
  @overload
175
186
  def query(
176
- fn: Callable[[TState], Coroutine[Any, Any, T]],
187
+ fn: Callable[[TState], Awaitable[T]],
177
188
  *,
178
- keep_alive: bool = False,
189
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
190
+ stale_time: float = 0.0,
191
+ gc_time: float | None = 300.0,
179
192
  keep_previous_data: bool = False,
180
- ) -> QueryProperty[T, TState]: ...
193
+ retries: int = 3,
194
+ retry_delay: float | None = None,
195
+ initial: T | Callable[[TState], T],
196
+ on_success: OnSuccessFn[TState, T] | None = None,
197
+ on_error: OnErrorFn[TState] | None = None,
198
+ ) -> QueryPropertyWithInitial[T, TState]: ...
199
+
200
+
181
201
  @overload
182
202
  def query(
183
- fn: None = None, *, keep_alive: bool = False, keep_previous_data: bool = False
203
+ fn: None = None,
204
+ *,
205
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
206
+ stale_time: float = 0.0,
207
+ gc_time: float | None = 300.0,
208
+ keep_previous_data: bool = False,
209
+ retries: int = 3,
210
+ retry_delay: float | None = None,
211
+ initial: T | Callable[[TState], T],
212
+ on_success: OnSuccessFn[TState, T] | None = None,
213
+ on_error: OnErrorFn[TState] | None = None,
184
214
  ) -> Callable[
185
- [Callable[[TState], Coroutine[Any, Any, T]]], QueryProperty[T, TState]
215
+ [Callable[[TState], Awaitable[T]]], QueryPropertyWithInitial[T, TState]
186
216
  ]: ...
187
217
 
188
218
 
189
- # When an initial value is provided, the resulting property narrows data to non-None
190
219
  @overload
191
220
  def query(
192
- fn: Callable[[TState], Coroutine[Any, Any, T]],
221
+ fn: Callable[[TState], Awaitable[T]],
193
222
  *,
194
- keep_alive: bool = False,
223
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
224
+ stale_time: float = 0.0,
225
+ gc_time: float | None = 300.0,
195
226
  keep_previous_data: bool = False,
196
- initial: T,
197
- ) -> QueryPropertyWithInitial[T, TState]: ...
227
+ retries: int = 3,
228
+ retry_delay: float | None = None,
229
+ initial: T | Callable[[TState], T] | None = ...,
230
+ on_success: OnSuccessFn[TState, T] | None = None,
231
+ on_error: OnErrorFn[TState] | None = None,
232
+ ) -> QueryProperty[T, TState]: ...
233
+
234
+
198
235
  @overload
199
236
  def query(
200
237
  fn: None = None,
201
238
  *,
202
- keep_alive: bool = False,
239
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
240
+ stale_time: float = 0.0,
241
+ gc_time: float | None = 300.0,
203
242
  keep_previous_data: bool = False,
204
- initial: T,
205
- ) -> Callable[
206
- [Callable[[TState], Coroutine[Any, Any, T]]], QueryPropertyWithInitial[T, TState]
207
- ]: ...
243
+ retries: int = 3,
244
+ retry_delay: float | None = None,
245
+ initial: T | Callable[[TState], T] | None = ...,
246
+ on_success: OnSuccessFn[TState, T] | None = None,
247
+ on_error: OnErrorFn[TState] | None = None,
248
+ ) -> Callable[[Callable[[TState], Awaitable[T]]], QueryProperty[T, TState]]: ...
208
249
 
209
250
 
210
251
  def query(
211
- fn: Callable[[TState], Any] | None = None,
252
+ fn: Callable[[TState], Awaitable[T]] | None = None,
212
253
  *,
213
- keep_alive: bool = False,
254
+ key: Callable[[TState], tuple[Any, ...]] | None = None,
255
+ stale_time: float = 0.0,
256
+ gc_time: float | None = 300.0,
214
257
  keep_previous_data: bool = False,
215
- initial: Any = None,
216
- ) -> (
217
- QueryProperty[T, TState]
218
- | QueryPropertyWithInitial[T, TState]
219
- | Callable[
220
- [Callable[[TState], Coroutine[Any, Any, T]]],
221
- QueryProperty[T, TState] | QueryPropertyWithInitial[T, TState],
222
- ]
258
+ retries: int = 3,
259
+ retry_delay: float | None = None,
260
+ initial: T | Callable[[TState], T] | None = MISSING,
261
+ on_success: OnSuccessFn[TState, T] | None = None,
262
+ on_error: OnErrorFn[TState] | None = None,
223
263
  ):
224
- def decorator(func: Callable[[TState], Coroutine[Any, Any, T]], /):
264
+ def decorator(
265
+ func: Callable[[TState], Awaitable[T]], /
266
+ ) -> QueryProperty[T, TState] | QueryPropertyWithInitial[T, TState]:
225
267
  sig = inspect.signature(func)
226
268
  params = list(sig.parameters.values())
227
269
  # Only state-method form supported for now (single 'self')
228
270
  if not (len(params) == 1 and params[0].name == "self"):
229
271
  raise TypeError("@query currently only supports state methods (self)")
230
- if initial is not None:
231
- return QueryPropertyWithInitial(
232
- func.__name__,
233
- func,
234
- keep_alive=keep_alive,
235
- keep_previous_data=keep_previous_data,
236
- initial=initial,
237
- )
238
- return QueryProperty(
272
+
273
+ prop_cls = QueryPropertyWithInitial if initial is not None else QueryProperty
274
+
275
+ return prop_cls(
239
276
  func.__name__,
240
277
  func,
241
- keep_alive=keep_alive,
278
+ key=key,
279
+ stale_time=stale_time,
280
+ gc_time=gc_time if gc_time is not None else 300.0,
242
281
  keep_previous_data=keep_previous_data,
282
+ retries=retries,
283
+ retry_delay=RETRY_DELAY_DEFAULT if retry_delay is None else retry_delay,
284
+ initial=initial,
285
+ on_success=on_success,
286
+ on_error=on_error,
287
+ )
288
+
289
+ if fn:
290
+ return decorator(fn)
291
+ return decorator
292
+
293
+
294
+ # -----------------
295
+ # Mutation decorator
296
+ # -----------------
297
+ @overload
298
+ def mutation(
299
+ fn: Callable[Concatenate[TState, P], Awaitable[T]],
300
+ *,
301
+ on_success: OnSuccessFn[TState, T] | None = None,
302
+ on_error: OnErrorFn[TState] | None = None,
303
+ ) -> MutationProperty[T, TState, P]: ...
304
+
305
+
306
+ @overload
307
+ def mutation(
308
+ fn: None = None,
309
+ *,
310
+ on_success: OnSuccessFn[TState, T] | None = None,
311
+ on_error: OnErrorFn[TState] | None = None,
312
+ ) -> Callable[
313
+ [Callable[Concatenate[TState, P], Awaitable[T]]], MutationProperty[T, TState, P]
314
+ ]: ...
315
+
316
+
317
+ def mutation(
318
+ fn: Callable[Concatenate[TState, P], Awaitable[T]] | None = None,
319
+ *,
320
+ on_success: OnSuccessFn[TState, T] | None = None,
321
+ on_error: OnErrorFn[TState] | None = None,
322
+ ):
323
+ def decorator(func: Callable[Concatenate[TState, P], Awaitable[T]], /):
324
+ sig = inspect.signature(func)
325
+ params = list(sig.parameters.values())
326
+
327
+ if len(params) == 0 or params[0].name != "self":
328
+ raise TypeError("@mutation method must have 'self' as first argument")
329
+
330
+ return MutationProperty(
331
+ func.__name__,
332
+ func,
333
+ on_success=on_success,
334
+ on_error=on_error,
243
335
  )
244
336
 
245
337
  if fn:
pulse/form.py CHANGED
@@ -1,10 +1,9 @@
1
1
  import json
2
2
  import uuid
3
- from collections.abc import Callable, Coroutine
3
+ from collections.abc import Awaitable, Callable
4
4
  from dataclasses import dataclass
5
5
  from typing import (
6
6
  TYPE_CHECKING,
7
- Any,
8
7
  Never,
9
8
  TypedDict,
10
9
  Unpack,
@@ -16,7 +15,7 @@ from starlette.datastructures import FormData as StarletteFormData
16
15
  from starlette.datastructures import UploadFile
17
16
 
18
17
  from pulse.context import PulseContext
19
- from pulse.helpers import call_flexible, maybe_await
18
+ from pulse.helpers import Disposable, call_flexible, maybe_await
20
19
  from pulse.hooks.core import HOOK_CONTEXT, HookMetadata, HookState, hooks
21
20
  from pulse.hooks.runtime import server_address
22
21
  from pulse.hooks.stable import stable
@@ -60,10 +59,10 @@ class FormRegistration:
60
59
  render_id: str
61
60
  route_path: str
62
61
  session_id: str
63
- on_submit: Callable[[FormData], Coroutine[Any, Any, None]]
62
+ on_submit: Callable[[FormData], Awaitable[None]]
64
63
 
65
64
 
66
- class FormRegistry:
65
+ class FormRegistry(Disposable):
67
66
  def __init__(self, render: "RenderSession") -> None:
68
67
  self._render: "RenderSession" = render
69
68
  self._handlers: dict[str, FormRegistration] = {}
@@ -73,7 +72,7 @@ class FormRegistry:
73
72
  render_id: str,
74
73
  route_id: str,
75
74
  session_id: str,
76
- on_submit: Callable[[FormData], Coroutine[Any, Any, None]],
75
+ on_submit: Callable[[FormData], Awaitable[None]],
77
76
  ) -> FormRegistration:
78
77
  registration = FormRegistration(
79
78
  uuid.uuid4().hex,
@@ -88,9 +87,9 @@ class FormRegistry:
88
87
  def unregister(self, form_id: str) -> None:
89
88
  self._handlers.pop(form_id, None)
90
89
 
90
+ @override
91
91
  def dispose(self) -> None:
92
- for form_id in list(self._handlers.keys()):
93
- self.unregister(form_id)
92
+ self._handlers.clear()
94
93
 
95
94
  async def handle_submit(
96
95
  self,
@@ -203,7 +202,7 @@ class GeneratedFormProps(TypedDict):
203
202
  onSubmit: Callable[[], None]
204
203
 
205
204
 
206
- class ManualForm:
205
+ class ManualForm(Disposable):
207
206
  _submit_signal: Signal[bool]
208
207
  _render: "RenderSession"
209
208
  _registration: FormRegistration | None
@@ -267,6 +266,7 @@ class ManualForm:
267
266
  props.update(self.props()) # pyright: ignore[reportCallIssue, reportArgumentType]
268
267
  return client_form_component(*children, key=key, **props)
269
268
 
269
+ @override
270
270
  def dispose(self) -> None:
271
271
  if self._registration is None:
272
272
  return
pulse/helpers.py CHANGED
@@ -2,13 +2,26 @@ import asyncio
2
2
  import inspect
3
3
  import os
4
4
  import socket
5
- from collections.abc import Awaitable, Callable, Coroutine
6
- from typing import Any, ParamSpec, Protocol, TypedDict, TypeVar, overload, override
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Awaitable, Callable
7
+ from functools import wraps
8
+ from typing import (
9
+ Any,
10
+ ParamSpec,
11
+ Protocol,
12
+ Self,
13
+ TypedDict,
14
+ TypeVar,
15
+ overload,
16
+ override,
17
+ )
7
18
  from urllib.parse import urlsplit
8
19
 
9
20
  from anyio import from_thread
10
21
  from fastapi import Request
11
22
 
23
+ from pulse.env import env
24
+
12
25
 
13
26
  def values_equal(a: Any, b: Any) -> bool:
14
27
  """Robust equality that avoids ambiguous truth for DataFrames/ndarrays.
@@ -86,9 +99,34 @@ def data(**attrs: Any):
86
99
 
87
100
 
88
101
  # --- Async scheduling helpers (work from loop or sync threads) ---
102
+ class Disposable(ABC):
103
+ __disposed__: bool = False
104
+
105
+ @abstractmethod
106
+ def dispose(self) -> None: ...
107
+
108
+ def __init_subclass__(cls, **kwargs: Any):
109
+ super().__init_subclass__(**kwargs)
110
+
111
+ if "dispose" in cls.__dict__:
112
+ original_dispose = cls.dispose
113
+
114
+ @wraps(original_dispose)
115
+ def wrapped_dispose(self: Self, *args: Any, **kwargs: Any):
116
+ if self.__disposed__:
117
+ if env.pulse_env == "dev":
118
+ cls_name = type(self).__name__
119
+ raise RuntimeError(
120
+ f"{self} (type={cls_name}) was disposed twice. This is likely a bug."
121
+ )
122
+ return
123
+ self.__disposed__ = True
124
+ return original_dispose(self, *args, **kwargs)
89
125
 
126
+ cls.dispose = wrapped_dispose
90
127
 
91
- def _running_under_pytest() -> bool:
128
+
129
+ def is_pytest() -> bool:
92
130
  """Detect if running inside pytest using environment variables."""
93
131
  return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
94
132
  "PYTEST_XDIST_TESTRUNUID" in os.environ
@@ -109,12 +147,12 @@ def schedule_on_loop(callback: Callable[[], None]) -> None:
109
147
  try:
110
148
  from_thread.run(_runner)
111
149
  except RuntimeError:
112
- if not _running_under_pytest():
150
+ if not is_pytest():
113
151
  raise
114
152
 
115
153
 
116
154
  def create_task(
117
- coroutine: Coroutine[Any, Any, T],
155
+ coroutine: Awaitable[T],
118
156
  *,
119
157
  name: str | None = None,
120
158
  on_done: Callable[[asyncio.Task[T]], None] | None = None,
@@ -126,16 +164,22 @@ def create_task(
126
164
  """
127
165
 
128
166
  try:
129
- loop = asyncio.get_running_loop()
130
- task = loop.create_task(coroutine, name=name)
167
+ asyncio.get_running_loop()
168
+ # ensure_future accepts Awaitable and returns a Task when given a coroutine
169
+ task = asyncio.ensure_future(coroutine)
170
+ if name is not None:
171
+ task.set_name(name)
131
172
  if on_done:
132
173
  task.add_done_callback(on_done)
133
174
  return task
134
175
  except RuntimeError:
135
176
 
136
177
  async def _runner():
137
- loop = asyncio.get_running_loop()
138
- task = loop.create_task(coroutine, name=name)
178
+ asyncio.get_running_loop()
179
+ # ensure_future accepts Awaitable and returns a Task when given a coroutine
180
+ task = asyncio.ensure_future(coroutine)
181
+ if name is not None:
182
+ task.set_name(name)
139
183
  if on_done:
140
184
  task.add_done_callback(on_done)
141
185
  return task
@@ -143,7 +187,7 @@ def create_task(
143
187
  try:
144
188
  return from_thread.run(_runner)
145
189
  except RuntimeError:
146
- if _running_under_pytest():
190
+ if is_pytest():
147
191
  return None # pyright: ignore[reportReturnType]
148
192
  raise
149
193
 
@@ -418,9 +462,29 @@ async def maybe_await(value: T | Awaitable[T]) -> T:
418
462
  def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
419
463
  """Find an available port starting from start_port."""
420
464
  for port in range(start_port, start_port + max_attempts):
465
+ # First check if something is actively listening on the port
466
+ # by trying to connect to it (check both IPv4 and IPv6)
467
+ port_in_use = False
468
+ for family, addr in [(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")]:
469
+ try:
470
+ with socket.socket(family, socket.SOCK_STREAM) as test_socket:
471
+ test_socket.settimeout(0.1)
472
+ result = test_socket.connect_ex((addr, port))
473
+ # If connection succeeds (result == 0), something is listening
474
+ if result == 0:
475
+ port_in_use = True
476
+ break
477
+ except OSError:
478
+ # Connection failed, continue checking
479
+ pass
480
+
481
+ if port_in_use:
482
+ continue
483
+
484
+ # Port appears free, try to bind to it
485
+ # Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
421
486
  try:
422
487
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
423
- # Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
424
488
  s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
425
489
  s.bind(("localhost", port))
426
490
  return port
pulse/hooks/core.py CHANGED
@@ -3,9 +3,9 @@ import logging
3
3
  from collections.abc import Callable, Mapping
4
4
  from contextvars import ContextVar, Token
5
5
  from dataclasses import dataclass
6
- from typing import Any, Generic, TypeVar
6
+ from typing import Any, Generic, TypeVar, override
7
7
 
8
- from pulse.helpers import call_flexible
8
+ from pulse.helpers import Disposable, call_flexible
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -37,7 +37,7 @@ class HookMetadata:
37
37
  extra: Mapping[str, Any] | None = None
38
38
 
39
39
 
40
- class HookState:
40
+ class HookState(Disposable):
41
41
  """Base class returned by hook factories."""
42
42
 
43
43
  render_cycle: int
@@ -52,6 +52,7 @@ class HookState:
52
52
  """Called after the component render has completed."""
53
53
  ...
54
54
 
55
+ @override
55
56
  def dispose(self) -> None:
56
57
  """Called when the hook instance is discarded."""
57
58
  ...
pulse/hooks/states.py CHANGED
@@ -17,48 +17,106 @@ S9 = TypeVar("S9", bound=State)
17
17
  S10 = TypeVar("S10", bound=State)
18
18
 
19
19
 
20
+ class StateNamespace:
21
+ __slots__: tuple[str, ...] = ("states", "key", "called")
22
+ states: tuple[State, ...]
23
+ key: str | None
24
+ called: bool
25
+
26
+ def __init__(self, key: str | None) -> None:
27
+ self.states = ()
28
+ self.key = key
29
+ self.called = False
30
+
31
+ def ensure_not_called(self) -> None:
32
+ if self.called:
33
+ key_msg = (
34
+ f" with key='{self.key}'" if self.key is not None else " without a key"
35
+ )
36
+ raise RuntimeError(
37
+ f"`pulse.states` can only be called once per component render{key_msg}"
38
+ )
39
+
40
+ def get_or_create_states(
41
+ self, args: tuple[State | Callable[[], State], ...]
42
+ ) -> tuple[State, ...]:
43
+ if len(self.states) > 0:
44
+ # Reuse existing states
45
+ existing_states = self.states
46
+ # Validate that the number of arguments matches
47
+ if len(args) != len(existing_states):
48
+ key_msg = (
49
+ f" with key='{self.key}'"
50
+ if self.key is not None
51
+ else " without a key"
52
+ )
53
+ raise RuntimeError(
54
+ f"`pulse.states` called with {len(args)} argument(s) but was previously "
55
+ + f"called with {len(existing_states)} argument(s){key_msg}. "
56
+ + "The number of arguments must remain consistent across renders."
57
+ )
58
+ # Dispose any State instances passed directly as args that aren't being used
59
+ existing_set = set(existing_states)
60
+ for arg in args:
61
+ if isinstance(arg, State) and arg not in existing_set:
62
+ try:
63
+ if not arg.__disposed__:
64
+ arg.dispose()
65
+ except RuntimeError:
66
+ # Already disposed, ignore
67
+ pass
68
+ return existing_states
69
+
70
+ # Create new states
71
+ instances = tuple(_instantiate_state(arg) for arg in args)
72
+ self.states = instances
73
+ return instances
74
+
75
+ def dispose(self) -> None:
76
+ for state in self.states:
77
+ try:
78
+ if not state.__disposed__:
79
+ state.dispose()
80
+ except RuntimeError:
81
+ # Already disposed, ignore
82
+ pass
83
+ self.states = ()
84
+
85
+
20
86
  class StatesHookState(HookState):
21
- __slots__: tuple[str, ...] = ("initialized", "states", "key", "_called")
22
- initialized: bool
23
- _called: bool
87
+ __slots__: tuple[str, ...] = ("namespaces",)
88
+ namespaces: dict[str | None, StateNamespace]
24
89
 
25
90
  def __init__(self) -> None:
26
91
  super().__init__()
27
- self.initialized = False
28
- self.states: tuple[State, ...] = ()
29
- self.key: str | None = None
30
- self._called = False
92
+ self.namespaces = {}
31
93
 
32
94
  @override
33
95
  def on_render_start(self, render_cycle: int) -> None:
34
96
  super().on_render_start(render_cycle)
35
- self._called = False
36
-
37
- def replace(self, states: list[State], key: str | None) -> None:
38
- self.dispose_states()
39
- self.states = tuple(states)
40
- self.key = key
41
- self.initialized = True
42
-
43
- def dispose_states(self) -> None:
44
- for state in self.states:
45
- state.dispose()
46
- self.states = ()
47
- self.initialized = False
48
- self.key = None
97
+ if self.namespaces:
98
+ for namespace in self.namespaces.values():
99
+ namespace.called = False
100
+
101
+ def get_namespace(self, key: str | None) -> StateNamespace:
102
+ if key not in self.namespaces:
103
+ self.namespaces[key] = StateNamespace(key)
104
+ return self.namespaces[key]
105
+
106
+ def get_or_create_states(
107
+ self, args: tuple[State | Callable[[], State], ...], key: str | None
108
+ ) -> tuple[State, ...]:
109
+ namespace = self.get_namespace(key)
110
+ namespace.ensure_not_called()
111
+ result = namespace.get_or_create_states(args)
112
+ namespace.called = True
113
+ return result
49
114
 
50
115
  @override
51
116
  def dispose(self) -> None:
52
- self.dispose_states()
53
-
54
- def ensure_not_called(self) -> None:
55
- if self._called:
56
- raise RuntimeError(
57
- "`pulse.states` can only be called once per component render"
58
- )
59
-
60
- def mark_called(self) -> None:
61
- self._called = True
117
+ for namespace in self.namespaces.values():
118
+ namespace.dispose()
119
+ self.namespaces.clear()
62
120
 
63
121
 
64
122
  def _instantiate_state(arg: State | Callable[[], State]) -> State:
@@ -219,29 +277,8 @@ def states(*args: S | Callable[[], S], key: str | None = ...) -> tuple[S, ...]:
219
277
 
220
278
 
221
279
  def states(*args: State | Callable[[], State], key: str | None = None):
222
- state = _states_hook()
223
- state.ensure_not_called()
224
-
225
- if not state.initialized:
226
- instances = [_instantiate_state(arg) for arg in args]
227
- state.replace(instances, key)
228
- state.mark_called()
229
- result = state.states
230
- return result[0] if len(result) == 1 else result
231
-
232
- if key is not None and key != state.key:
233
- instances = [_instantiate_state(arg) for arg in args]
234
- state.replace(instances, key)
235
- state.mark_called()
236
- result = state.states
237
- return result[0] if len(result) == 1 else result
238
-
239
- for arg in args:
240
- if isinstance(arg, State):
241
- arg.dispose()
242
-
243
- state.mark_called()
244
- result = state.states
280
+ hook_state = _states_hook()
281
+ result = hook_state.get_or_create_states(args, key)
245
282
  return result[0] if len(result) == 1 else result
246
283
 
247
284
 
pulse/messages.py CHANGED
@@ -175,6 +175,6 @@ class Directives(TypedDict):
175
175
  socketio: SocketIODirectives
176
176
 
177
177
 
178
- class PrerenderResult(TypedDict):
178
+ class Prerender(TypedDict):
179
179
  views: dict[str, ServerInitMessage | None]
180
180
  directives: Directives