pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__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/reactive.py CHANGED
@@ -1,18 +1,24 @@
1
1
  import asyncio
2
2
  import copy
3
3
  import inspect
4
- from collections.abc import Callable, Coroutine
4
+ from collections.abc import Awaitable, Callable
5
5
  from contextvars import ContextVar, Token
6
6
  from typing import (
7
7
  Any,
8
8
  Generic,
9
+ Literal,
9
10
  ParamSpec,
10
11
  TypeVar,
11
- cast,
12
12
  override,
13
13
  )
14
14
 
15
- from pulse.helpers import create_task, schedule_on_loop, values_equal
15
+ from pulse.helpers import (
16
+ Disposable,
17
+ create_task,
18
+ maybe_await,
19
+ schedule_on_loop,
20
+ values_equal,
21
+ )
16
22
 
17
23
  T = TypeVar("T")
18
24
  P = ParamSpec("P")
@@ -94,6 +100,7 @@ class Computed(Generic[T]):
94
100
  name: str | None
95
101
  dirty: bool
96
102
  on_stack: bool
103
+ accepts_prev_value: bool
97
104
 
98
105
  def __init__(self, fn: Callable[..., T], name: str | None = None):
99
106
  self.fn = fn
@@ -106,6 +113,18 @@ class Computed(Generic[T]):
106
113
  self.deps: dict[Signal[Any] | Computed[Any], int] = {}
107
114
  self.obs: list[Computed[Any] | Effect] = []
108
115
  self._obs_change_listeners: list[Callable[[int], None]] = []
116
+ sig = inspect.signature(self.fn)
117
+ params = list(sig.parameters.values())
118
+ # Check if function has at least one positional parameter
119
+ # (excluding *args and **kwargs, and keyword-only params)
120
+ self.accepts_prev_value = any(
121
+ p.kind
122
+ in (
123
+ inspect.Parameter.POSITIONAL_ONLY,
124
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
125
+ )
126
+ for p in params
127
+ )
109
128
 
110
129
  def read(self) -> T:
111
130
  if self.on_stack:
@@ -156,7 +175,10 @@ class Computed(Generic[T]):
156
175
  self.on_stack = True
157
176
  try:
158
177
  execution_epoch = epoch()
159
- self.value = self.fn()
178
+ if self.accepts_prev_value:
179
+ self.value = self.fn(prev_value)
180
+ else:
181
+ self.value = self.fn()
160
182
  if epoch() != execution_epoch:
161
183
  raise RuntimeError(
162
184
  f"Detected write to a signal in computed {self.name}. Computeds should be read-only."
@@ -231,10 +253,10 @@ class Computed(Generic[T]):
231
253
  EffectCleanup = Callable[[], None]
232
254
  # Split effect function types into sync and async for clearer typing
233
255
  EffectFn = Callable[[], EffectCleanup | None]
234
- AsyncEffectFn = Callable[[], Coroutine[Any, Any, EffectCleanup | None]]
256
+ AsyncEffectFn = Callable[[], Awaitable[EffectCleanup | None]]
235
257
 
236
258
 
237
- class Effect:
259
+ class Effect(Disposable):
238
260
  """
239
261
  Synchronous effect and base class. Use AsyncEffect for async effects.
240
262
  Both are isinstance(Effect).
@@ -247,6 +269,7 @@ class Effect:
247
269
  last_run: int
248
270
  immediate: bool
249
271
  _lazy: bool
272
+ explicit_deps: bool
250
273
  batch: "Batch | None"
251
274
 
252
275
  def __init__(
@@ -269,13 +292,19 @@ class Effect:
269
292
  self.last_run = -1
270
293
  self.scope: Scope | None = None
271
294
  self.batch = None
272
- self._explicit_deps: list[Signal[Any] | Computed[Any]] | None = deps
295
+ self.explicit_deps = deps is not None
273
296
  self.immediate = immediate
274
297
  self._lazy = lazy
275
298
 
276
299
  if immediate and lazy:
277
300
  raise ValueError("An effect cannot be boht immediate and lazy")
278
301
 
302
+ # Register explicit dependencies immediately upon initialization
303
+ if deps is not None:
304
+ self.deps = {dep: dep.last_change for dep in deps}
305
+ for dep in deps:
306
+ dep.add_obs(self)
307
+
279
308
  rc = REACTIVE_CONTEXT.get()
280
309
  if rc.scope is not None:
281
310
  rc.scope.register_effect(self)
@@ -291,6 +320,7 @@ class Effect:
291
320
  if self.cleanup_fn:
292
321
  self.cleanup_fn()
293
322
 
323
+ @override
294
324
  def dispose(self):
295
325
  self.unschedule()
296
326
  for child in self.children.copy():
@@ -353,15 +383,24 @@ class Effect:
353
383
  return
354
384
  raise exc
355
385
 
356
- def _apply_scope_results(self, scope: "Scope") -> None:
386
+ def _apply_scope_results(
387
+ self,
388
+ scope: "Scope",
389
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None,
390
+ ) -> None:
391
+ # Apply captured last_change values at the end for explicit deps
392
+ if self.explicit_deps:
393
+ assert captured_last_changes is not None
394
+ for dep, last_change in captured_last_changes.items():
395
+ self.deps[dep] = last_change
396
+ return
397
+
357
398
  self.children = scope.effects
358
399
  for child in self.children:
359
400
  child.parent = self
360
401
 
361
402
  prev_deps = set(self.deps)
362
- if self._explicit_deps is not None:
363
- self.deps = {dep: dep.last_change for dep in self._explicit_deps}
364
- else:
403
+ if not self.explicit_deps:
365
404
  self.deps = scope.deps
366
405
  new_deps = set(self.deps)
367
406
  add_deps = new_deps - prev_deps
@@ -379,8 +418,8 @@ class Effect:
379
418
 
380
419
  def _copy_kwargs(self) -> dict[str, Any]:
381
420
  deps = None
382
- if self._explicit_deps is not None:
383
- deps = list(self._explicit_deps)
421
+ if self.explicit_deps:
422
+ deps = list(self.deps.keys())
384
423
  return {
385
424
  "fn": self.fn,
386
425
  "name": self.name,
@@ -418,6 +457,10 @@ class Effect:
418
457
 
419
458
  def _execute(self) -> None:
420
459
  execution_epoch = epoch()
460
+ # Capture last_change for explicit deps before running
461
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = None
462
+ if self.explicit_deps:
463
+ captured_last_changes = {dep: dep.last_change for dep in self.deps}
421
464
  with Scope() as scope:
422
465
  # Clear batch *before* running as we may update a signal that causes
423
466
  # this effect to be rescheduled.
@@ -428,11 +471,13 @@ class Effect:
428
471
  self.handle_error(e)
429
472
  self.runs += 1
430
473
  self.last_run = execution_epoch
431
- self._apply_scope_results(scope)
474
+ self._apply_scope_results(scope, captured_last_changes)
432
475
 
433
476
 
434
477
  class AsyncEffect(Effect):
435
- batch: "Batch | None"
478
+ fn: AsyncEffectFn # pyright: ignore[reportIncompatibleMethodOverride]
479
+ batch: None # pyright: ignore[reportIncompatibleVariableOverride]
480
+ _task: asyncio.Task[None] | None
436
481
 
437
482
  def __init__(
438
483
  self,
@@ -442,6 +487,8 @@ class AsyncEffect(Effect):
442
487
  on_error: Callable[[Exception], None] | None = None,
443
488
  deps: list[Signal[Any] | Computed[Any]] | None = None,
444
489
  ):
490
+ # Track an async task when running async effects
491
+ self._task = None
445
492
  super().__init__(
446
493
  fn=fn, # pyright: ignore[reportArgumentType]
447
494
  name=name,
@@ -450,12 +497,19 @@ class AsyncEffect(Effect):
450
497
  on_error=on_error,
451
498
  deps=deps,
452
499
  )
453
- # Track an async task when running async effects
454
- self._task: asyncio.Task[Any] | None = None
455
500
 
456
- def _task_name(self) -> str:
457
- base = self.name or "effect"
458
- return f"effect:{base}"
501
+ @override
502
+ def schedule(self):
503
+ """
504
+ Schedule the async effect. Unlike synchronous effects, async effects do not
505
+ go through batches, they cancel the previous run and create a new task
506
+ immediately..
507
+ """
508
+ self.run()
509
+
510
+ @property
511
+ def is_scheduled(self) -> bool:
512
+ return self._task is not None
459
513
 
460
514
  @override
461
515
  def _copy_kwargs(self):
@@ -464,48 +518,91 @@ class AsyncEffect(Effect):
464
518
  return kwargs
465
519
 
466
520
  @override
467
- def _execute(self) -> None:
521
+ def run(self) -> asyncio.Task[Any]: # pyright: ignore[reportIncompatibleMethodOverride]
522
+ """
523
+ Run the async effect immediately, cancelling any previous run.
524
+ Returns the asyncio.Task.
525
+ """
468
526
  execution_epoch = epoch()
469
527
 
470
- # Clear batch *before* running as we may update a signal that causes
471
- # this effect to be rescheduled.
472
- self.batch = None
473
-
474
528
  # Cancel any previous run still in flight
475
529
  self.cancel()
530
+ this_task: asyncio.Task[None] | None = None
476
531
 
477
532
  async def _runner():
478
- nonlocal execution_epoch
479
- with Scope() as scope:
480
- try:
481
- result = cast(AsyncEffectFn, self.fn)()
482
- if inspect.isawaitable(result):
483
- self.cleanup_fn = await result
484
- else:
485
- # Support accidental non-async returns in async-annotated fns
486
- self.cleanup_fn = result
487
- except asyncio.CancelledError:
488
- # Swallow cancellation
489
- return
490
- except Exception as e:
491
- self.handle_error(e)
492
- self.runs += 1
493
- self.last_run = execution_epoch
494
- self._apply_scope_results(scope)
495
-
496
- self._task = create_task(_runner(), name=self._task_name())
533
+ nonlocal execution_epoch, this_task
534
+ try:
535
+ # Perform cleanups in the new task
536
+ with Untrack():
537
+ try:
538
+ self._cleanup_before_run()
539
+ except Exception as e:
540
+ self.handle_error(e)
541
+
542
+ # Capture last_change for explicit deps before running
543
+ captured_last_changes: dict[Signal[Any] | Computed[Any], int] | None = (
544
+ None
545
+ )
546
+ if self.explicit_deps:
547
+ captured_last_changes = {dep: dep.last_change for dep in self.deps}
548
+
549
+ with Scope() as scope:
550
+ try:
551
+ result = self.fn()
552
+ self.cleanup_fn = await maybe_await(result)
553
+ except asyncio.CancelledError:
554
+ # Re-raise so finally block executes to clear task reference
555
+ raise
556
+ except Exception as e:
557
+ self.handle_error(e)
558
+ self.runs += 1
559
+ self.last_run = execution_epoch
560
+ self._apply_scope_results(scope, captured_last_changes)
561
+ finally:
562
+ # Clear the task reference when it finishes
563
+ if self._task is this_task:
564
+ self._task = None
565
+
566
+ this_task = create_task(_runner(), name=f"effect:{self.name or 'unnamed'}")
567
+ self._task = this_task
568
+ return this_task
569
+
570
+ @override
571
+ async def __call__(self): # pyright: ignore[reportIncompatibleMethodOverride]
572
+ await self.run()
497
573
 
498
574
  def cancel(self) -> None:
499
- self.unschedule()
500
- if self._task and not self._task.done():
501
- self._task.cancel()
575
+ # No batch removal needed as AsyncEffect is not batched
576
+ if self._task:
577
+ t = self._task
578
+ self._task = None
579
+ if not t.cancelled():
580
+ t.cancel()
581
+
582
+ async def wait(self) -> None:
583
+ """
584
+ Wait until the completion of the current task if it's already running,
585
+ or start a run if it's not running. In case of cancellation, awaits
586
+ the new task by recursively calling itself.
587
+ """
588
+ while True:
589
+ try:
590
+ await (self._task or self.run())
591
+ return
592
+ except asyncio.CancelledError:
593
+ # If wait() itself is cancelled, propagate it
594
+ current_task = asyncio.current_task()
595
+ if current_task is not None and (
596
+ current_task.cancelling() > 0 or current_task.cancelled()
597
+ ):
598
+ raise
599
+ # Effect task was cancelled, continue waiting for new task
600
+ continue
502
601
 
503
602
  @override
504
603
  def dispose(self):
505
604
  # Run children cleanups first, then cancel in-flight task
506
- self.unschedule()
507
- if self._task and not self._task.done():
508
- self._task.cancel()
605
+ self.cancel()
509
606
  for child in self.children.copy():
510
607
  child.dispose()
511
608
  if self.cleanup_fn:
@@ -579,11 +676,12 @@ class Batch:
579
676
  exc_type: type[BaseException] | None,
580
677
  exc_value: BaseException | None,
581
678
  exc_traceback: Any,
582
- ):
679
+ ) -> Literal[False]:
583
680
  self.flush()
584
681
  # Restore previous reactive context
585
682
  if self._token:
586
683
  REACTIVE_CONTEXT.reset(self._token)
684
+ return False
587
685
 
588
686
 
589
687
  class GlobalBatch(Batch):
@@ -659,10 +757,11 @@ class Scope:
659
757
  exc_type: type[BaseException] | None,
660
758
  exc_value: BaseException | None,
661
759
  exc_traceback: Any,
662
- ):
760
+ ) -> Literal[False]:
663
761
  # Restore previous reactive context
664
762
  if self._token:
665
763
  REACTIVE_CONTEXT.reset(self._token)
764
+ return False
666
765
 
667
766
 
668
767
  class Untrack(Scope): ...
@@ -705,8 +804,9 @@ class ReactiveContext:
705
804
  exc_type: type[BaseException] | None,
706
805
  exc_value: BaseException | None,
707
806
  exc_tb: Any,
708
- ):
807
+ ) -> Literal[False]:
709
808
  REACTIVE_CONTEXT.reset(self._tokens.pop())
809
+ return False
710
810
 
711
811
 
712
812
  def epoch():
pulse/render_session.py CHANGED
@@ -18,6 +18,7 @@ from pulse.messages import (
18
18
  ServerNavigateToMessage,
19
19
  ServerUpdateMessage,
20
20
  )
21
+ from pulse.queries.store import QueryStore
21
22
  from pulse.reactive import Effect, flush_effects
22
23
  from pulse.renderer import RenderTree
23
24
  from pulse.routing import (
@@ -26,7 +27,7 @@ from pulse.routing import (
26
27
  RouteContext,
27
28
  RouteInfo,
28
29
  RouteTree,
29
- normalize_path,
30
+ ensure_absolute_path,
30
31
  )
31
32
  from pulse.state import State
32
33
  from pulse.vdom import Element
@@ -60,6 +61,7 @@ class RenderSession:
60
61
  routes: RouteTree
61
62
  channels: "ChannelsManager"
62
63
  forms: "FormRegistry"
64
+ query_store: QueryStore
63
65
 
64
66
  def __init__(
65
67
  self,
@@ -85,6 +87,7 @@ class RenderSession:
85
87
  self._pending_api: dict[str, asyncio.Future[dict[str, Any]]] = {}
86
88
  # Registry of per-session global singletons (created via ps.global_state without id)
87
89
  self._global_states: dict[str, State] = {}
90
+ self.query_store = QueryStore()
88
91
  # Connection state
89
92
  self.connected: bool = False
90
93
  self.channels = ChannelsManager(self)
@@ -353,7 +356,7 @@ class RenderSession:
353
356
  self,
354
357
  path: str,
355
358
  ):
356
- path = normalize_path(path)
359
+ path = ensure_absolute_path(path)
357
360
  mount = self.route_mounts.get(path)
358
361
  if not mount:
359
362
  raise ValueError(f"No active route for '{path}'")
pulse/routing.py CHANGED
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
4
4
  from typing import TypedDict, cast, override
5
5
 
6
6
  from pulse.css import CssImport, CssModule
7
+ from pulse.env import env
7
8
  from pulse.react_component import ReactComponent
8
9
  from pulse.reactive_extensions import ReactiveDict
9
10
  from pulse.vdom import Component
@@ -81,7 +82,7 @@ def parse_route_path(path: str) -> list[PathSegment]:
81
82
 
82
83
  # Normalize to react-router's convention: no leading and trailing slashes. Empty
83
84
  # string interpreted as the root.
84
- def normalize_path(path: str):
85
+ def ensure_relative_path(path: str):
85
86
  if path.startswith("/"):
86
87
  path = path[1:]
87
88
  if path.endswith("/"):
@@ -89,6 +90,12 @@ def normalize_path(path: str):
89
90
  return path
90
91
 
91
92
 
93
+ def ensure_absolute_path(path: str):
94
+ if not path.startswith("/"):
95
+ path = "/" + path
96
+ return path
97
+
98
+
92
99
  # ---- Shared helpers ----------------------------------------------------------
93
100
  def segments_are_dynamic(segments: list[PathSegment]) -> bool:
94
101
  """Return True if any segment is dynamic, optional, or a catch-all."""
@@ -157,6 +164,7 @@ class Route:
157
164
  css_imports: Sequence[CssImport] | None
158
165
  is_index: bool
159
166
  is_dynamic: bool
167
+ dev: bool
160
168
 
161
169
  def __init__(
162
170
  self,
@@ -166,8 +174,9 @@ class Route:
166
174
  components: "Sequence[ReactComponent[...]] | None" = None,
167
175
  css_modules: Sequence[CssModule] | None = None,
168
176
  css_imports: Sequence[CssImport] | None = None,
177
+ dev: bool = False,
169
178
  ):
170
- self.path = normalize_path(path)
179
+ self.path = ensure_relative_path(path)
171
180
  self.segments = parse_route_path(path)
172
181
 
173
182
  self.render = render
@@ -175,6 +184,7 @@ class Route:
175
184
  self.components = components
176
185
  self.css_modules = css_modules
177
186
  self.css_imports = css_imports
187
+ self.dev = dev
178
188
  self.parent: Route | Layout | None = None
179
189
 
180
190
  self.is_index = self.path == ""
@@ -191,8 +201,8 @@ class Route:
191
201
  return [path]
192
202
 
193
203
  def unique_path(self):
194
- # Ensure consistent keys without accidental leading/trailing slashes
195
- return normalize_path("/".join(self._path_list()))
204
+ # Return absolute path with leading '/'
205
+ return ensure_absolute_path("/".join(self._path_list()))
196
206
 
197
207
  def file_path(self) -> str:
198
208
  path = "/".join(self._path_list(include_layouts=False))
@@ -224,8 +234,7 @@ class Route:
224
234
  f"Cannot build default RouteInfo for dynamic route '{self.path}'."
225
235
  )
226
236
 
227
- unique = self.unique_path()
228
- pathname = "/" if unique == "" else f"/{unique}"
237
+ pathname = self.unique_path()
229
238
  return {
230
239
  "pathname": pathname,
231
240
  "hash": "",
@@ -250,6 +259,7 @@ class Layout:
250
259
  components: Sequence[ReactComponent[...]] | None
251
260
  css_modules: Sequence[CssModule] | None
252
261
  css_imports: Sequence[CssImport] | None
262
+ dev: bool
253
263
 
254
264
  def __init__(
255
265
  self,
@@ -258,12 +268,14 @@ class Layout:
258
268
  components: "Sequence[ReactComponent[...]] | None" = None,
259
269
  css_modules: Sequence[CssModule] | None = None,
260
270
  css_imports: Sequence[CssImport] | None = None,
271
+ dev: bool = False,
261
272
  ):
262
273
  self.render = render
263
274
  self.children = children or []
264
275
  self.components = components
265
276
  self.css_modules = css_modules
266
277
  self.css_imports = css_imports
278
+ self.dev = dev
267
279
  self.parent: Route | Layout | None = None
268
280
  # 1-based sibling index assigned by RouteTree at each level
269
281
  self.idx: int = 1
@@ -280,7 +292,9 @@ class Layout:
280
292
  return path_list
281
293
 
282
294
  def unique_path(self):
283
- return "/".join(self._path_list(include_layouts=True))
295
+ # Return absolute path with leading '/'
296
+ path = "/".join(self._path_list(include_layouts=True))
297
+ return f"/{path}"
284
298
 
285
299
  def file_path(self) -> str:
286
300
  path_list = self._path_list(include_layouts=True)
@@ -318,8 +332,7 @@ class Layout:
318
332
 
319
333
  # Build pathname from ancestor route path segments (exclude layout indicators)
320
334
  path_list = self._path_list(include_layouts=False)
321
- unique = normalize_path("/".join(path_list))
322
- pathname = "/" if unique == "" else f"/{unique}"
335
+ pathname = ensure_absolute_path("/".join(path_list))
323
336
  return {
324
337
  "pathname": pathname,
325
338
  "hash": "",
@@ -330,6 +343,48 @@ class Layout:
330
343
  }
331
344
 
332
345
 
346
+ def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
347
+ """
348
+ Filter out routes with dev=True.
349
+
350
+ This function removes all routes marked with dev=True from the route tree.
351
+ Should only be called when env != "dev".
352
+ """
353
+ filtered: list[Route | Layout] = []
354
+ for route in routes:
355
+ # Skip dev-only routes
356
+ if route.dev:
357
+ continue
358
+
359
+ # Recursively filter children
360
+ if route.children:
361
+ filtered_children = filter_dev_routes(route.children)
362
+ # Create a copy of the route with filtered children
363
+ if isinstance(route, Route):
364
+ filtered_route = Route(
365
+ path=route.path,
366
+ render=route.render,
367
+ children=filtered_children,
368
+ components=route.components,
369
+ css_modules=route.css_modules,
370
+ css_imports=route.css_imports,
371
+ dev=route.dev,
372
+ )
373
+ else: # Layout
374
+ filtered_route = Layout(
375
+ render=route.render,
376
+ children=filtered_children,
377
+ components=route.components,
378
+ css_modules=route.css_modules,
379
+ css_imports=route.css_imports,
380
+ dev=route.dev,
381
+ )
382
+ filtered.append(filtered_route)
383
+ else:
384
+ filtered.append(route)
385
+ return filtered
386
+
387
+
333
388
  class InvalidRouteError(Exception): ...
334
389
 
335
390
 
@@ -338,6 +393,9 @@ class RouteTree:
338
393
  flat_tree: dict[str, Route | Layout]
339
394
 
340
395
  def __init__(self, routes: Sequence[Route | Layout]) -> None:
396
+ # Filter out dev routes when not in dev environment
397
+ if env.pulse_env != "dev":
398
+ routes = filter_dev_routes(routes)
341
399
  self.tree = list(routes)
342
400
  self.flat_tree = {}
343
401
 
@@ -366,7 +424,7 @@ class RouteTree:
366
424
  _flatten_route_tree(route)
367
425
 
368
426
  def find(self, path: str):
369
- path = normalize_path(path)
427
+ path = ensure_absolute_path(path)
370
428
  route = self.flat_tree.get(path)
371
429
  if not route:
372
430
  raise ValueError(f"No route found for path '{path}'")
pulse/state.py CHANGED
@@ -11,6 +11,7 @@ from collections.abc import Callable, Iterator
11
11
  from enum import IntEnum
12
12
  from typing import Any, Generic, Never, TypeVar, override
13
13
 
14
+ from pulse.helpers import Disposable
14
15
  from pulse.reactive import (
15
16
  AsyncEffect,
16
17
  Computed,
@@ -185,7 +186,7 @@ class StateStatus(IntEnum):
185
186
  STATE_STATUS_FIELD = "__pulse_status__"
186
187
 
187
188
 
188
- class State(ABC, metaclass=StateMeta):
189
+ class State(Disposable, metaclass=StateMeta):
189
190
  """
190
191
  Base class for reactive state objects.
191
192
 
@@ -328,19 +329,19 @@ class State(ABC, metaclass=StateMeta):
328
329
  """
329
330
  pass
330
331
 
332
+ @override
331
333
  def dispose(self):
332
334
  # Call user-defined cleanup hook first
333
- self.on_dispose()
334
335
 
335
- disposed = set()
336
+ self.on_dispose()
336
337
  for value in self.__dict__.values():
337
- if isinstance(value, Effect):
338
+ if isinstance(value, Disposable):
338
339
  value.dispose()
339
- disposed.add(value)
340
340
 
341
- if len(set(self._scope.effects) - disposed) > 0:
341
+ undisposed_effects = [e for e in self._scope.effects if not e.__disposed__]
342
+ if len(undisposed_effects) > 0:
342
343
  raise RuntimeError(
343
- f"State.dispose() missed effects defined on its Scope: {[e.name for e in self._scope.effects]}"
344
+ f"State.dispose() missed effects defined on its Scope: {[e.name for e in undisposed_effects]}"
344
345
  )
345
346
 
346
347
  @override
@@ -1,10 +1,9 @@
1
- from collections.abc import Callable, Coroutine
1
+ from collections.abc import Awaitable, Callable
2
2
  from typing import (
3
- Any,
4
3
  TypeVar,
5
4
  )
6
5
 
7
- EventHandlerResult = None | Coroutine[Any, Any, None]
6
+ EventHandlerResult = None | Awaitable[None]
8
7
 
9
8
  T1 = TypeVar("T1", contravariant=True)
10
9
  T2 = TypeVar("T2", contravariant=True)
pulse/user_session.py CHANGED
@@ -12,6 +12,7 @@ from fastapi import Response
12
12
 
13
13
  from pulse.cookies import SetCookie
14
14
  from pulse.env import env
15
+ from pulse.helpers import Disposable
15
16
  from pulse.reactive import AsyncEffect, Effect
16
17
  from pulse.reactive_extensions import ReactiveDict, reactive
17
18
 
@@ -23,7 +24,7 @@ Session = ReactiveDict[str, Any]
23
24
  logger = logging.getLogger(__name__)
24
25
 
25
26
 
26
- class UserSession:
27
+ class UserSession(Disposable):
27
28
  sid: str
28
29
  data: Session
29
30
  app: "App"
@@ -65,8 +66,8 @@ class UserSession:
65
66
  max_age_seconds=app.cookie.max_age_seconds,
66
67
  )
67
68
 
69
+ @override
68
70
  def dispose(self):
69
- print(f"Closing session {self.sid}")
70
71
  self._effect.dispose()
71
72
 
72
73
  def handle_response(self, res: Response):
pulse/vdom.py CHANGED
@@ -23,6 +23,7 @@ from typing import (
23
23
  )
24
24
 
25
25
  from pulse.hooks.core import HookContext
26
+ from pulse.hooks.init import rewrite_init_blocks
26
27
 
27
28
  # ============================================================================
28
29
  # Core VDOM
@@ -291,7 +292,8 @@ def component(
291
292
  fn: "Callable[P, Element] | None" = None, *, name: str | None = None
292
293
  ) -> "Component[P] | Callable[[Callable[P, Element]], Component[P]]":
293
294
  def decorator(fn: Callable[P, Element]):
294
- return Component(fn, name)
295
+ rewritten = rewrite_init_blocks(fn)
296
+ return Component(rewritten, name)
295
297
 
296
298
  if fn is not None:
297
299
  return decorator(fn)