pulse-framework 0.1.72__tar.gz → 0.1.73__tar.gz

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.
Files changed (131) hide show
  1. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/PKG-INFO +2 -2
  2. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/pyproject.toml +2 -2
  3. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/__init__.py +16 -4
  4. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/processes.py +2 -0
  5. pulse_framework-0.1.73/src/pulse/debounce.py +79 -0
  6. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/decorators.py +4 -3
  7. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/effects.py +5 -5
  8. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/runtime.py +25 -8
  9. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/setup.py +6 -10
  10. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/stable.py +5 -9
  11. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/state.py +4 -8
  12. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/common.py +1 -1
  13. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/infinite_query.py +2 -1
  14. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/mutation.py +2 -1
  15. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/query.py +2 -1
  16. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/render_session.py +2 -2
  17. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/renderer.py +30 -2
  18. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/routing.py +19 -5
  19. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/serializer.py +38 -19
  20. pulse_framework-0.1.73/src/pulse/state/__init__.py +1 -0
  21. pulse_framework-0.1.73/src/pulse/state/property.py +218 -0
  22. pulse_framework-0.1.73/src/pulse/state/query_param.py +538 -0
  23. {pulse_framework-0.1.72/src/pulse → pulse_framework-0.1.73/src/pulse/state}/state.py +66 -220
  24. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/nodes.py +26 -2
  25. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/transpiler.py +86 -5
  26. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/vdom.py +1 -1
  27. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/README.md +0 -0
  28. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/_examples.py +0 -0
  29. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/app.py +0 -0
  30. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/channel.py +0 -0
  31. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/__init__.py +0 -0
  32. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/cmd.py +0 -0
  33. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/dependencies.py +0 -0
  34. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/folder_lock.py +0 -0
  35. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/helpers.py +0 -0
  36. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/logging.py +0 -0
  37. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/models.py +0 -0
  38. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/packages.py +0 -0
  39. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/secrets.py +0 -0
  40. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cli/uvicorn_log_config.py +0 -0
  41. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/code_analysis.py +0 -0
  42. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/__init__.py +0 -0
  43. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/codegen.py +0 -0
  44. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/templates/__init__.py +0 -0
  45. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/templates/layout.py +0 -0
  46. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/templates/route.py +0 -0
  47. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/templates/routes_ts.py +0 -0
  48. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/codegen/utils.py +0 -0
  49. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/component.py +0 -0
  50. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/components/__init__.py +0 -0
  51. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/components/for_.py +0 -0
  52. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/components/if_.py +0 -0
  53. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/components/react_router.py +0 -0
  54. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/context.py +0 -0
  55. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/cookies.py +0 -0
  56. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/__init__.py +0 -0
  57. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/elements.py +0 -0
  58. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/events.py +0 -0
  59. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/props.py +0 -0
  60. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/svg.py +0 -0
  61. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/tags.py +0 -0
  62. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/dom/tags.pyi +0 -0
  63. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/env.py +0 -0
  64. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/forms.py +0 -0
  65. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/helpers.py +0 -0
  66. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/__init__.py +0 -0
  67. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/core.py +0 -0
  68. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/hooks/init.py +0 -0
  69. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/__init__.py +0 -0
  70. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/__init__.pyi +0 -0
  71. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/_types.py +0 -0
  72. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/array.py +0 -0
  73. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/console.py +0 -0
  74. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/date.py +0 -0
  75. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/document.py +0 -0
  76. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/error.py +0 -0
  77. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/json.py +0 -0
  78. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/map.py +0 -0
  79. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/math.py +0 -0
  80. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/navigator.py +0 -0
  81. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/number.py +0 -0
  82. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/obj.py +0 -0
  83. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/object.py +0 -0
  84. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/promise.py +0 -0
  85. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/pulse.py +0 -0
  86. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/react.py +0 -0
  87. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/regexp.py +0 -0
  88. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/set.py +0 -0
  89. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/string.py +0 -0
  90. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/weakmap.py +0 -0
  91. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/weakset.py +0 -0
  92. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/js/window.py +0 -0
  93. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/messages.py +0 -0
  94. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/middleware.py +0 -0
  95. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/plugin.py +0 -0
  96. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/proxy.py +0 -0
  97. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/py.typed +0 -0
  98. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/__init__.py +0 -0
  99. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/client.py +0 -0
  100. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/effect.py +0 -0
  101. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/protocol.py +0 -0
  102. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/queries/store.py +0 -0
  103. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/react_component.py +0 -0
  104. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/reactive.py +0 -0
  105. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/reactive_extensions.py +0 -0
  106. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/request.py +0 -0
  107. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/requirements.py +0 -0
  108. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/scheduling.py +0 -0
  109. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/test_helpers.py +0 -0
  110. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/__init__.py +0 -0
  111. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/assets.py +0 -0
  112. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/builtins.py +0 -0
  113. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/dynamic_import.py +0 -0
  114. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/emit_context.py +0 -0
  115. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/errors.py +0 -0
  116. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/function.py +0 -0
  117. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/id.py +0 -0
  118. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/imports.py +0 -0
  119. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/js_module.py +0 -0
  120. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/__init__.py +0 -0
  121. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/asyncio.py +0 -0
  122. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/json.py +0 -0
  123. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/math.py +0 -0
  124. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  125. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  126. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/typing.py +0 -0
  127. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/transpiler/py_module.py +0 -0
  128. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/types/__init__.py +0 -0
  129. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/types/event_handler.py +0 -0
  130. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/user_session.py +0 -0
  131. {pulse_framework-0.1.72 → pulse_framework-0.1.73}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.72
3
+ Version: 0.1.73
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: fastapi>=0.128.0
6
6
  Requires-Dist: uvicorn>=0.24.0
@@ -15,7 +15,7 @@ Requires-Dist: urllib3>=2.6.3
15
15
  Requires-Dist: watchfiles>=1.1.0
16
16
  Requires-Dist: httpx>=0.28.1
17
17
  Requires-Dist: aiohttp>=3.12.0
18
- Requires-Python: >=3.11
18
+ Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
 
21
21
  # Pulse Python
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.72"
3
+ version = "0.1.73"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "fastapi>=0.128.0",
9
9
  "uvicorn>=0.24.0",
@@ -60,6 +60,14 @@ from pulse.context import PulseContext as PulseContext
60
60
  from pulse.cookies import Cookie as Cookie
61
61
  from pulse.cookies import SetCookie as SetCookie
62
62
 
63
+ # Debounce
64
+ from pulse.debounce import (
65
+ Debounced as Debounced,
66
+ )
67
+ from pulse.debounce import (
68
+ debounced as debounced,
69
+ )
70
+
63
71
  # Decorators
64
72
  from pulse.decorators import computed as computed
65
73
  from pulse.decorators import effect as effect
@@ -1200,7 +1208,7 @@ from pulse.hooks.core import (
1200
1208
  )
1201
1209
 
1202
1210
  # Hooks - Effects (import to register inline_effect_hook before registry locks)
1203
- from pulse.hooks.effects import InlineEffectHookState as InlineEffectHookState
1211
+ from pulse.hooks.effects import EffectState as EffectState
1204
1212
 
1205
1213
  # Hooks - Init
1206
1214
  from pulse.hooks.init import (
@@ -1235,6 +1243,9 @@ from pulse.hooks.runtime import (
1235
1243
  from pulse.hooks.runtime import (
1236
1244
  not_found as not_found,
1237
1245
  )
1246
+ from pulse.hooks.runtime import (
1247
+ pulse_route as pulse_route,
1248
+ )
1238
1249
  from pulse.hooks.runtime import (
1239
1250
  redirect as redirect,
1240
1251
  )
@@ -1259,7 +1270,7 @@ from pulse.hooks.runtime import (
1259
1270
 
1260
1271
  # Hooks - Setup
1261
1272
  from pulse.hooks.setup import (
1262
- SetupHookState as SetupHookState,
1273
+ SetupState as SetupState,
1263
1274
  )
1264
1275
  from pulse.hooks.setup import (
1265
1276
  setup as setup,
@@ -1271,7 +1282,7 @@ from pulse.hooks.stable import (
1271
1282
  StableEntry as StableEntry,
1272
1283
  )
1273
1284
  from pulse.hooks.stable import (
1274
- StableRegistry as StableRegistry,
1285
+ StableState as StableState,
1275
1286
  )
1276
1287
 
1277
1288
  # Hooks - Stable
@@ -1433,7 +1444,8 @@ from pulse.serializer import deserialize as deserialize
1433
1444
  from pulse.serializer import serialize as serialize
1434
1445
 
1435
1446
  # State and routing
1436
- from pulse.state import State as State
1447
+ from pulse.state.query_param import QueryParam as QueryParam
1448
+ from pulse.state.state import State as State
1437
1449
 
1438
1450
  # Transpiler v2
1439
1451
  from pulse.transpiler.function import JsFunction as JsFunction
@@ -272,6 +272,8 @@ def _write_tagged_line(name: str, message: str, tag_mode: TagMode) -> None:
272
272
  "Network: use --host to expose" in clean_message
273
273
  or "press h + enter to show help" in clean_message
274
274
  or "➜ Local:" in clean_message
275
+ or "/__manifest" in clean_message
276
+ or "?import" in clean_message
275
277
  ):
276
278
  return
277
279
 
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Generic, ParamSpec, TypeVar
8
+
9
+ from pulse.context import PULSE_CONTEXT
10
+ from pulse.scheduling import TimerHandleLike, later
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Debounced(Generic[P, R]):
18
+ fn: Callable[P, R]
19
+ delay_ms: float
20
+ _handle: TimerHandleLike | asyncio.Handle | None = field(
21
+ default=None, init=False, repr=False, compare=False
22
+ )
23
+
24
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Any:
25
+ if self._handle is not None:
26
+ self._handle.cancel()
27
+
28
+ delay = self.delay_ms / 1000.0
29
+
30
+ def _run() -> None:
31
+ object.__setattr__(self, "_handle", None)
32
+ result = self.fn(*args, **kwargs)
33
+ if asyncio.iscoroutine(result):
34
+ loop = asyncio.get_running_loop()
35
+ task = loop.create_task(result)
36
+
37
+ def _log_task_exception(t: asyncio.Task[Any]) -> None:
38
+ try:
39
+ t.result()
40
+ except asyncio.CancelledError:
41
+ pass
42
+ except Exception as exc:
43
+ loop.call_exception_handler(
44
+ {
45
+ "message": "Unhandled exception in debounced() task",
46
+ "exception": exc,
47
+ "context": {"callback": self.fn},
48
+ }
49
+ )
50
+
51
+ task.add_done_callback(_log_task_exception)
52
+
53
+ if PULSE_CONTEXT.get() is not None:
54
+ handle = later(delay, _run)
55
+ else:
56
+ try:
57
+ loop = asyncio.get_running_loop()
58
+ except RuntimeError:
59
+ try:
60
+ loop = asyncio.get_event_loop()
61
+ except RuntimeError as exc:
62
+ raise RuntimeError("debounced() requires an event loop") from exc
63
+ handle = loop.call_later(delay, _run)
64
+
65
+ object.__setattr__(self, "_handle", handle)
66
+
67
+
68
+ def debounced(fn: Callable[P, R], delay_ms: int | float) -> Debounced[P, R]:
69
+ """Return a debounced callback marker (delay in milliseconds)."""
70
+ if not callable(fn):
71
+ raise TypeError("debounced() requires a callable")
72
+ if isinstance(delay_ms, bool) or not isinstance(delay_ms, (int, float)):
73
+ raise TypeError("debounced() delay must be a number (ms)")
74
+ if not math.isfinite(delay_ms) or delay_ms < 0:
75
+ raise ValueError("debounced() delay must be finite and >= 0")
76
+ return Debounced(fn=fn, delay_ms=float(delay_ms))
77
+
78
+
79
+ __all__ = ["Debounced", "debounced"]
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
5
5
  from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
6
6
 
7
7
  from pulse.hooks.core import HOOK_CONTEXT
8
- from pulse.hooks.effects import inline_effect_hook
8
+ from pulse.hooks.effects import effect_state
9
9
  from pulse.hooks.state import collect_component_identity
10
10
  from pulse.reactive import (
11
11
  AsyncEffect,
@@ -16,7 +16,8 @@ from pulse.reactive import (
16
16
  EffectFn,
17
17
  Signal,
18
18
  )
19
- from pulse.state import ComputedProperty, State, StateEffect
19
+ from pulse.state.property import ComputedProperty, StateEffect
20
+ from pulse.state.state import State
20
21
 
21
22
  T = TypeVar("T")
22
23
  TState = TypeVar("TState", bound=State)
@@ -336,7 +337,7 @@ def effect(
336
337
  else:
337
338
  identity = key
338
339
 
339
- state = inline_effect_hook()
340
+ state = effect_state()
340
341
  return state.get_or_create(cast(Any, identity), key, create_effect)
341
342
 
342
343
  if fn is not None:
@@ -5,7 +5,7 @@ from pulse.hooks.core import HookMetadata, HookState, hooks
5
5
  from pulse.reactive import REACTIVE_CONTEXT, AsyncEffect, Effect
6
6
 
7
7
 
8
- class InlineEffectHookState(HookState):
8
+ class EffectState(HookState):
9
9
  """Stores inline effects keyed by function identity or explicit key."""
10
10
 
11
11
  __slots__ = ("effects", "_seen_this_render") # pyright: ignore[reportUnannotatedClassAttribute]
@@ -86,9 +86,9 @@ class InlineEffectHookState(HookState):
86
86
  self._seen_this_render.clear()
87
87
 
88
88
 
89
- inline_effect_hook = hooks.create(
89
+ effect_state = hooks.create(
90
90
  "pulse:core.inline_effects",
91
- lambda: InlineEffectHookState(),
91
+ factory=EffectState,
92
92
  metadata=HookMetadata(
93
93
  owner="pulse.core",
94
94
  description="Storage for inline @ps.effect decorators in components",
@@ -97,6 +97,6 @@ inline_effect_hook = hooks.create(
97
97
 
98
98
 
99
99
  __all__ = [
100
- "InlineEffectHookState",
101
- "inline_effect_hook",
100
+ "EffectState",
101
+ "effect_state",
102
102
  ]
@@ -13,8 +13,8 @@ from typing import (
13
13
  from pulse.context import PulseContext
14
14
  from pulse.hooks.core import HOOK_CONTEXT
15
15
  from pulse.reactive_extensions import ReactiveDict
16
- from pulse.routing import RouteContext
17
- from pulse.state import State
16
+ from pulse.routing import Layout, Route, RouteInfo
17
+ from pulse.state.state import State
18
18
 
19
19
 
20
20
  class RedirectInterrupt(Exception):
@@ -47,11 +47,11 @@ class NotFoundInterrupt(Exception):
47
47
  pass
48
48
 
49
49
 
50
- def route() -> RouteContext:
51
- """Get the current route context.
50
+ def route() -> RouteInfo:
51
+ """Get the current route info.
52
52
 
53
53
  Returns:
54
- RouteContext: Object with access to route parameters, path, and query.
54
+ RouteInfo: Mapping with access to route parameters, path, and query.
55
55
 
56
56
  Raises:
57
57
  RuntimeError: If called outside of a component render context.
@@ -61,8 +61,8 @@ def route() -> RouteContext:
61
61
  ```python
62
62
  def user_page():
63
63
  r = ps.route()
64
- user_id = r.params.get("user_id") # From /users/:user_id
65
- page = r.query.get("page", "1") # From ?page=2
64
+ user_id = r["pathParams"].get("user_id") # From /users/:user_id
65
+ page = r["queryParams"].get("page", "1") # From ?page=2
66
66
  return m.Text(f"User {user_id}, Page {page}")
67
67
  ```
68
68
  """
@@ -71,7 +71,24 @@ def route() -> RouteContext:
71
71
  raise RuntimeError(
72
72
  "`pulse.route` can only be called within a component during rendering."
73
73
  )
74
- return ctx.route
74
+ return ctx.route.info
75
+
76
+
77
+ def pulse_route() -> Route | Layout:
78
+ """Get the current route definition.
79
+
80
+ Returns:
81
+ Route | Layout: The active route or layout definition.
82
+
83
+ Raises:
84
+ RuntimeError: If called outside of a component render context.
85
+ """
86
+ ctx = PulseContext.get()
87
+ if not ctx or not ctx.route:
88
+ raise RuntimeError(
89
+ "`pulse.pulse_route` can only be called within a component during rendering."
90
+ )
91
+ return ctx.route.pulse_route
75
92
 
76
93
 
77
94
  def session() -> ReactiveDict[str, Any]:
@@ -10,7 +10,7 @@ P = ParamSpec("P")
10
10
  T = TypeVar("T")
11
11
 
12
12
 
13
- class SetupHookState(HookState):
13
+ class SetupState(HookState):
14
14
  """Internal hook state for the setup hook.
15
15
 
16
16
  Manages the initialization, argument tracking, and lifecycle of
@@ -140,13 +140,9 @@ class SetupHookState(HookState):
140
140
  return key
141
141
 
142
142
 
143
- def _setup_factory():
144
- return SetupHookState()
145
-
146
-
147
- _setup_hook = hooks.create(
143
+ setup_state = hooks.create(
148
144
  "pulse:core.setup",
149
- _setup_factory,
145
+ factory=SetupState,
150
146
  metadata=HookMetadata(
151
147
  owner="pulse.core",
152
148
  description="Internal storage for pulse.setup hook",
@@ -195,7 +191,7 @@ def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
195
191
  - Use ``ps.setup()`` directly when AST rewriting is problematic
196
192
  - Arguments must be consistent across renders (same count and names)
197
193
  """
198
- state = _setup_hook()
194
+ state = setup_state()
199
195
  state.ensure_not_called()
200
196
 
201
197
  key = state.consume_pending_key()
@@ -245,10 +241,10 @@ def setup_key(key: str) -> None:
245
241
  """
246
242
  if not isinstance(key, str):
247
243
  raise TypeError("setup_key() requires a string key")
248
- state = _setup_hook()
244
+ state = setup_state()
249
245
  if state.called_this_render:
250
246
  raise RuntimeError("setup_key() must be called before setup() in a render")
251
247
  state.set_pending_key(key)
252
248
 
253
249
 
254
- __all__ = ["setup", "setup_key", "SetupHookState"]
250
+ __all__ = ["setup", "setup_key", "SetupState"]
@@ -35,7 +35,7 @@ class StableEntry:
35
35
  self.wrapper = wrapper
36
36
 
37
37
 
38
- class StableRegistry(HookState):
38
+ class StableState(HookState):
39
39
  """Internal hook state that stores stable entries by key.
40
40
 
41
41
  Maintains a dictionary of StableEntry objects, allowing stable
@@ -50,13 +50,9 @@ class StableRegistry(HookState):
50
50
  self.entries: dict[str, StableEntry] = {}
51
51
 
52
52
 
53
- def _stable_factory(*_: object) -> StableRegistry:
54
- return StableRegistry()
55
-
56
-
57
- _stable_hook = hooks.create(
53
+ stable_state = hooks.create(
58
54
  "pulse:core.stable",
59
- _stable_factory,
55
+ factory=StableState,
60
56
  metadata=HookMetadata(
61
57
  owner="pulse.core",
62
58
  description="Internal registry for pulse.stable values",
@@ -119,7 +115,7 @@ def stable(key: str, value: Any = MISSING) -> Any:
119
115
  if not key:
120
116
  raise ValueError("stable() requires a non-empty string key")
121
117
 
122
- registry = _stable_hook()
118
+ registry = stable_state()
123
119
  entry = registry.entries.get(key)
124
120
 
125
121
  if value is not MISSING:
@@ -135,4 +131,4 @@ def stable(key: str, value: Any = MISSING) -> Any:
135
131
  return entry.wrapper
136
132
 
137
133
 
138
- __all__ = ["stable", "StableRegistry", "StableEntry"]
134
+ __all__ = ["stable", "StableState", "StableEntry"]
@@ -5,7 +5,7 @@ from typing import Any, TypeVar, override
5
5
 
6
6
  from pulse.component import is_component_code
7
7
  from pulse.hooks.core import HookMetadata, HookState, hooks
8
- from pulse.state import State
8
+ from pulse.state.state import State
9
9
 
10
10
  S = TypeVar("S", bound=State)
11
11
 
@@ -99,10 +99,6 @@ def _instantiate_state(arg: State | Callable[[], State]) -> State:
99
99
  return instance
100
100
 
101
101
 
102
- def _state_factory():
103
- return StateHookState()
104
-
105
-
106
102
  def _frame_offset(frame: FrameType) -> int:
107
103
  offset = frame.f_lasti
108
104
  if offset < 0:
@@ -123,9 +119,9 @@ def collect_component_identity(
123
119
  return tuple(identity[:1])
124
120
 
125
121
 
126
- _state_hook = hooks.create(
122
+ state_hook = hooks.create(
127
123
  "pulse:core.state",
128
- _state_factory,
124
+ factory=StateHookState,
129
125
  metadata=HookMetadata(
130
126
  owner="pulse.core",
131
127
  description="Internal storage for pulse.state hook",
@@ -185,7 +181,7 @@ def state(
185
181
  else:
186
182
  identity = resolved_key
187
183
 
188
- hook_state = _state_hook()
184
+ hook_state = state_hook()
189
185
  return hook_state.get_or_create_state(identity, resolved_key, resolved_arg) # pyright: ignore[reportReturnType]
190
186
 
191
187
 
@@ -13,7 +13,7 @@ from typing import (
13
13
  override,
14
14
  )
15
15
 
16
- from pulse.state import State
16
+ from pulse.state.state import State
17
17
 
18
18
  T = TypeVar("T")
19
19
  TState = TypeVar("TState", bound="State")
@@ -39,7 +39,8 @@ from pulse.queries.query import RETRY_DELAY_DEFAULT, QueryConfig
39
39
  from pulse.reactive import Computed, Effect, Signal, Untrack
40
40
  from pulse.reactive_extensions import ReactiveList, unwrap
41
41
  from pulse.scheduling import TimerHandleLike, create_task, later
42
- from pulse.state import InitializableProperty, State
42
+ from pulse.state.property import InitializableProperty
43
+ from pulse.state.state import State
43
44
 
44
45
  T = TypeVar("T")
45
46
  TParam = TypeVar("TParam")
@@ -13,7 +13,8 @@ from typing import (
13
13
  from pulse.helpers import call_flexible, maybe_await
14
14
  from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
15
15
  from pulse.reactive import Signal
16
- from pulse.state import InitializableProperty, State
16
+ from pulse.state.property import InitializableProperty
17
+ from pulse.state.state import State
17
18
 
18
19
  T = TypeVar("T")
19
20
  TState = TypeVar("TState", bound=State)
@@ -37,7 +37,8 @@ from pulse.queries.common import (
37
37
  from pulse.queries.effect import AsyncQueryEffect
38
38
  from pulse.reactive import Computed, Effect, Signal, Untrack
39
39
  from pulse.scheduling import TimerHandleLike, create_task, is_pytest, later
40
- from pulse.state import InitializableProperty, State
40
+ from pulse.state.property import InitializableProperty
41
+ from pulse.state.state import State
41
42
 
42
43
  if TYPE_CHECKING:
43
44
  from pulse.queries.protocol import QueryResult
@@ -34,7 +34,7 @@ from pulse.scheduling import (
34
34
  TimerRegistry,
35
35
  create_future,
36
36
  )
37
- from pulse.state import State
37
+ from pulse.state.state import State
38
38
  from pulse.transpiler.id import next_id
39
39
  from pulse.transpiler.nodes import Expr
40
40
 
@@ -111,7 +111,7 @@ class RouteMount:
111
111
  ) -> None:
112
112
  self.render = render
113
113
  self.path = ensure_absolute_path(path)
114
- self.route = RouteContext(route_info, route)
114
+ self.route = RouteContext(route_info, route, render)
115
115
  self.effect = None
116
116
  self._pulse_ctx = None
117
117
  self.tree = RenderTree(route.render())
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from types import NoneType
7
7
  from typing import Any, NamedTuple, TypeAlias, cast
8
8
 
9
+ from pulse.debounce import Debounced
9
10
  from pulse.helpers import values_equal
10
11
  from pulse.hooks.core import HookContext
11
12
  from pulse.transpiler import Import
@@ -33,7 +34,7 @@ from pulse.transpiler.vdom import (
33
34
  VDOMPropValue,
34
35
  )
35
36
 
36
- PropValue: TypeAlias = Node | Callable[..., Any]
37
+ PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any]
37
38
 
38
39
  FRAGMENT_TAG = ""
39
40
  MOUNT_PREFIX = "$$"
@@ -404,6 +405,21 @@ class Renderer:
404
405
  updated[key] = value.render()
405
406
  continue
406
407
 
408
+ if isinstance(value, Debounced):
409
+ eval_keys.add(key)
410
+ if isinstance(old_value, (Element, PulseNode)):
411
+ unmount_element(old_value)
412
+ if normalized is None:
413
+ normalized = current.copy()
414
+ normalized[key] = value
415
+ register_callback(self.callbacks, prop_path, value.fn)
416
+ prev_delay = (
417
+ old_value.delay_ms if isinstance(old_value, Debounced) else None
418
+ )
419
+ if prev_delay != value.delay_ms:
420
+ updated[key] = format_callback_placeholder(value.delay_ms)
421
+ continue
422
+
407
423
  if callable(value):
408
424
  eval_keys.add(key)
409
425
  if isinstance(old_value, (Element, PulseNode)):
@@ -412,7 +428,7 @@ class Renderer:
412
428
  normalized = current.copy()
413
429
  normalized[key] = value
414
430
  register_callback(self.callbacks, prop_path, value)
415
- if not callable(old_value):
431
+ if not callable(old_value) or isinstance(old_value, Debounced):
416
432
  updated[key] = CALLBACK_PLACEHOLDER
417
433
  continue
418
434
 
@@ -483,6 +499,8 @@ def prop_requires_eval(value: PropValue) -> bool:
483
499
  return True
484
500
  if isinstance(value, Expr):
485
501
  return True
502
+ if isinstance(value, Debounced):
503
+ return True
486
504
  return callable(value)
487
505
 
488
506
 
@@ -530,6 +548,16 @@ def normalize_children(children: Children | None) -> list[Node]:
530
548
  return out
531
549
 
532
550
 
551
+ def format_callback_placeholder(delay_ms: float | None) -> str:
552
+ if delay_ms is None:
553
+ return CALLBACK_PLACEHOLDER
554
+ if delay_ms.is_integer():
555
+ suffix = str(int(delay_ms))
556
+ else:
557
+ suffix = format(delay_ms, "g")
558
+ return f"{CALLBACK_PLACEHOLDER}:{suffix}"
559
+
560
+
533
561
  def register_callback(
534
562
  callbacks: Callbacks,
535
563
  path: str,
@@ -1,12 +1,16 @@
1
1
  import re
2
2
  from collections.abc import Sequence
3
3
  from dataclasses import dataclass, field
4
- from typing import TypedDict, cast, override
4
+ from typing import TYPE_CHECKING, TypedDict, cast, override
5
5
 
6
6
  from pulse.component import Component
7
7
  from pulse.env import env
8
8
  from pulse.reactive_extensions import ReactiveDict
9
9
 
10
+ if TYPE_CHECKING:
11
+ from pulse.render_session import RenderSession
12
+ from pulse.state.query_param import QueryParamSync
13
+
10
14
  # angle brackets cannot appear in a regular URL path, this ensures no name conflicts
11
15
  LAYOUT_INDICATOR = "<layout>"
12
16
 
@@ -516,7 +520,8 @@ class RouteContext:
516
520
  """Runtime context for the current route.
517
521
 
518
522
  Provides reactive access to the current route's URL components and
519
- parameters. Accessible via `ps.route()` in components.
523
+ parameters. Available via `ps.route()` (route info) and `ps.pulse_route()`
524
+ (route definition) in components.
520
525
 
521
526
  Attributes:
522
527
  info: Current route info (reactive, auto-updates on navigation).
@@ -534,18 +539,27 @@ class RouteContext:
534
539
  ```python
535
540
  @ps.component
536
541
  def UserProfile():
537
- ctx = ps.route()
538
- user_id = ctx.pathParams.get("id")
542
+ info = ps.route()
543
+ user_id = info["pathParams"].get("id")
539
544
  return ps.div(f"User: {user_id}")
540
545
  ```
541
546
  """
542
547
 
543
548
  info: RouteInfo
544
549
  pulse_route: Route | Layout
550
+ query_param_sync: "QueryParamSync"
545
551
 
546
- def __init__(self, info: RouteInfo, pulse_route: Route | Layout):
552
+ def __init__(
553
+ self,
554
+ info: RouteInfo,
555
+ pulse_route: Route | Layout,
556
+ render: "RenderSession",
557
+ ):
547
558
  self.info = cast(RouteInfo, cast(object, ReactiveDict(info)))
548
559
  self.pulse_route = pulse_route
560
+ from pulse.state.query_param import QueryParamSync
561
+
562
+ self.query_param_sync = QueryParamSync(render, self)
549
563
 
550
564
  def update(self, info: RouteInfo) -> None:
551
565
  """Update the route info with new values.