pulse-framework 0.1.53__py3-none-any.whl → 0.1.54__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/__init__.py CHANGED
@@ -1286,9 +1286,9 @@ from pulse.hooks.stable import (
1286
1286
  stable as stable,
1287
1287
  )
1288
1288
 
1289
- # Hooks - States
1290
- from pulse.hooks.states import StatesHookState as StatesHookState
1291
- from pulse.hooks.states import states as states
1289
+ # Hooks - State
1290
+ from pulse.hooks.state import StateHookState as StateHookState
1291
+ from pulse.hooks.state import state as state
1292
1292
  from pulse.messages import ClientMessage as ClientMessage
1293
1293
  from pulse.messages import Directives as Directives
1294
1294
  from pulse.messages import Prerender as Prerender
pulse/app.py CHANGED
@@ -21,6 +21,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
21
21
  from fastapi.middleware.cors import CORSMiddleware
22
22
  from fastapi.responses import JSONResponse
23
23
  from starlette.types import ASGIApp
24
+ from starlette.websockets import WebSocket
24
25
 
25
26
  from pulse.codegen.codegen import Codegen, CodegenConfig
26
27
  from pulse.context import PULSE_CONTEXT, PulseContext
@@ -28,6 +29,7 @@ from pulse.cookies import (
28
29
  Cookie,
29
30
  CORSOptions,
30
31
  compute_cookie_domain,
32
+ compute_cookie_secure,
31
33
  cors_options,
32
34
  session_cookie,
33
35
  )
@@ -364,6 +366,8 @@ class App:
364
366
  # Compute cookie domain from deployment/server address if not explicitly provided
365
367
  if self.cookie.domain is None:
366
368
  self.cookie.domain = compute_cookie_domain(self.mode, self.server_address)
369
+ if self.cookie.secure is None:
370
+ self.cookie.secure = compute_cookie_secure(self.env, self.server_address)
367
371
 
368
372
  # Add CORS middleware (configurable/overridable)
369
373
  if self.cors is not None:
@@ -450,7 +454,7 @@ class App:
450
454
  self._schedule_render_cleanup(render_id)
451
455
 
452
456
  async def _prerender_one(path: str):
453
- captured = render.prerender_mount_capture(path, route_info)
457
+ captured = render.prerender(path, route_info)
454
458
  if captured["type"] == "vdom_init":
455
459
  return Ok(captured)
456
460
  if captured["type"] == "navigate_to":
@@ -578,20 +582,20 @@ class App:
578
582
  server_address=server_address,
579
583
  )
580
584
 
585
+ # In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
586
+ # Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
587
+ if self.env == "dev":
588
+
589
+ @self.fastapi.websocket("/{path:path}")
590
+ async def websocket_proxy(websocket: WebSocket, path: str): # pyright: ignore[reportUnusedFunction]
591
+ await proxy_handler.proxy_websocket(websocket)
592
+
581
593
  @self.fastapi.api_route(
582
594
  "/{path:path}",
583
595
  methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
584
596
  include_in_schema=False,
585
597
  )
586
598
  async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
587
- # Skip WebSocket upgrades outside the Vite dev server (handled by Socket.IO)
588
- is_websocket_upgrade = (
589
- request.headers.get("upgrade", "").lower() == "websocket"
590
- )
591
- is_vite_dev_server = self.env == "dev" and request.url.path == "/"
592
- if is_websocket_upgrade and not is_vite_dev_server:
593
- raise HTTPException(status_code=404, detail="Not found")
594
-
595
599
  # Proxy all unmatched HTTP requests to React Router
596
600
  return await proxy_handler(request)
597
601
 
@@ -605,12 +609,14 @@ class App:
605
609
  # Parse cookies from environ and ensure a session exists
606
610
  cookie = self.cookie.get_from_socketio(environ)
607
611
  if cookie is None:
608
- raise ConnectionRefusedError()
612
+ raise ConnectionRefusedError("Socket connect missing cookie")
609
613
  session = await self.get_or_create_session(cookie)
610
614
 
611
615
  if not rid:
612
616
  # Still refuse connections without a renderId
613
- raise ConnectionRefusedError()
617
+ raise ConnectionRefusedError(
618
+ f"Socket connect missing render_id session={session.sid}"
619
+ )
614
620
 
615
621
  # Allow reconnects where the provided renderId no longer exists by creating a new RenderSession
616
622
  render = self.render_sessions.get(rid)
@@ -621,7 +627,10 @@ class App:
621
627
  else:
622
628
  owner = self._render_to_user.get(render.id)
623
629
  if owner != session.sid:
624
- raise ConnectionRefusedError()
630
+ raise ConnectionRefusedError(
631
+ f"Socket connect session mismatch render={render.id} "
632
+ + f"owner={owner} session={session.sid}"
633
+ )
625
634
 
626
635
  def on_message(message: ServerMessage):
627
636
  payload = serialize(message)
@@ -733,14 +742,14 @@ class App:
733
742
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
734
743
  ) -> None:
735
744
  async def _next() -> Ok[None]:
736
- if msg["type"] == "mount":
737
- render.mount(msg["path"], msg["routeInfo"])
738
- elif msg["type"] == "navigate":
739
- render.navigate(msg["path"], msg["routeInfo"])
745
+ if msg["type"] == "attach":
746
+ render.attach(msg["path"], msg["routeInfo"])
747
+ elif msg["type"] == "update":
748
+ render.update_route(msg["path"], msg["routeInfo"])
740
749
  elif msg["type"] == "callback":
741
750
  render.execute_callback(msg["path"], msg["callback"], msg["args"])
742
- elif msg["type"] == "unmount":
743
- render.unmount(msg["path"])
751
+ elif msg["type"] == "detach":
752
+ render.detach(msg["path"])
744
753
  render.channels.remove_route(msg["path"])
745
754
  elif msg["type"] == "api_result":
746
755
  render.handle_api_result(dict(msg))
@@ -845,6 +854,11 @@ class App:
845
854
 
846
855
  # Server-backed store path
847
856
  assert isinstance(self.session_store, SessionStore)
857
+ cookie_secure = self.cookie.secure
858
+ if cookie_secure is None:
859
+ raise RuntimeError(
860
+ "Cookie.secure is not resolved. Ensure App.setup() ran before sessions."
861
+ )
848
862
  if raw_cookie is not None:
849
863
  sid = raw_cookie
850
864
  data = await self.session_store.get(sid) or await self.session_store.create(
@@ -855,7 +869,7 @@ class App:
855
869
  name=self.cookie.name,
856
870
  value=sid,
857
871
  domain=self.cookie.domain,
858
- secure=self.cookie.secure,
872
+ secure=cookie_secure,
859
873
  samesite=self.cookie.samesite,
860
874
  max_age_seconds=self.cookie.max_age_seconds,
861
875
  )
@@ -871,7 +885,7 @@ class App:
871
885
  name=self.cookie.name,
872
886
  value=sid,
873
887
  domain=self.cookie.domain,
874
- secure=self.cookie.secure,
888
+ secure=cookie_secure,
875
889
  samesite=self.cookie.samesite,
876
890
  max_age_seconds=self.cookie.max_age_seconds,
877
891
  )
pulse/components/for_.py CHANGED
@@ -1,12 +1,23 @@
1
1
  from collections.abc import Callable, Iterable
2
2
  from inspect import Parameter, signature
3
- from typing import TypeVar, overload
3
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
4
4
 
5
- from pulse.transpiler.nodes import Element
5
+ from pulse.transpiler.nodes import Call, Element, Expr, Member, transformer
6
+
7
+ if TYPE_CHECKING:
8
+ from pulse.transpiler.transpiler import Transpiler
6
9
 
7
10
  T = TypeVar("T")
8
11
 
9
12
 
13
+ @transformer("For")
14
+ def emit_for(items: Any, fn: Any, *, ctx: "Transpiler") -> Expr:
15
+ """For(items, fn) -> items.map(fn)"""
16
+ items_expr = ctx.emit_expr(items)
17
+ fn_expr = ctx.emit_expr(fn)
18
+ return Call(Member(items_expr, "map"), [fn_expr])
19
+
20
+
10
21
  @overload
11
22
  def For(items: Iterable[T], fn: Callable[[T], Element]) -> list[Element]: ...
12
23
 
@@ -40,3 +51,7 @@ def For(items: Iterable[T], fn: Callable[..., Element]) -> list[Element]:
40
51
  if accepts_two:
41
52
  return [fn(item, idx) for idx, item in enumerate(items)]
42
53
  return [fn(item) for item in items]
54
+
55
+
56
+ # Register For in EXPR_REGISTRY so it can be used in transpiled functions
57
+ Expr.register(For, emit_for)
pulse/cookies.py CHANGED
@@ -5,6 +5,7 @@ from urllib.parse import urlparse
5
5
 
6
6
  from fastapi import Request, Response
7
7
 
8
+ from pulse.env import PulseEnv
8
9
  from pulse.hooks.runtime import set_cookie
9
10
 
10
11
  if TYPE_CHECKING:
@@ -16,7 +17,7 @@ class Cookie:
16
17
  name: str
17
18
  _: KW_ONLY
18
19
  domain: str | None = None
19
- secure: bool = True
20
+ secure: bool | None = None
20
21
  samesite: Literal["lax", "strict", "none"] = "lax"
21
22
  max_age_seconds: int = 7 * 24 * 3600
22
23
 
@@ -33,6 +34,10 @@ class Cookie:
33
34
  return cookies.get(self.name)
34
35
 
35
36
  async def set_through_api(self, value: str):
37
+ if self.secure is None:
38
+ raise RuntimeError(
39
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
40
+ )
36
41
  await set_cookie(
37
42
  name=self.name,
38
43
  value=value,
@@ -44,6 +49,10 @@ class Cookie:
44
49
 
45
50
  def set_on_fastapi(self, response: Response, value: str) -> None:
46
51
  """Set the session cookie on a FastAPI Response-like object."""
52
+ if self.secure is None:
53
+ raise RuntimeError(
54
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
55
+ )
47
56
  response.set_cookie(
48
57
  key=self.name,
49
58
  value=value,
@@ -62,6 +71,10 @@ class SetCookie(Cookie):
62
71
 
63
72
  @classmethod
64
73
  def from_cookie(cls, cookie: Cookie, value: str) -> "SetCookie":
74
+ if cookie.secure is None:
75
+ raise RuntimeError(
76
+ "Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
77
+ )
65
78
  return cls(
66
79
  name=cookie.name,
67
80
  value=value,
@@ -81,7 +94,7 @@ def session_cookie(
81
94
  return Cookie(
82
95
  name,
83
96
  domain=None,
84
- secure=False,
97
+ secure=None,
85
98
  samesite="lax",
86
99
  max_age_seconds=max_age_seconds,
87
100
  )
@@ -146,6 +159,29 @@ def compute_cookie_domain(mode: "PulseMode", server_address: str) -> str | None:
146
159
  return None
147
160
 
148
161
 
162
+ def compute_cookie_secure(env: PulseEnv, server_address: str | None) -> bool:
163
+ scheme = urlparse(server_address or "").scheme.lower()
164
+ if scheme in ("https", "wss"):
165
+ secure = True
166
+ elif scheme in ("http", "ws"):
167
+ secure = False
168
+ else:
169
+ secure = None
170
+ if secure is None:
171
+ if env in ("prod", "ci"):
172
+ raise RuntimeError(
173
+ "Could not determine cookie security from server_address. "
174
+ + "Use an explicit https:// server_address or set Cookie(secure=True/False)."
175
+ )
176
+ return False
177
+ if env in ("prod", "ci") and not secure:
178
+ raise RuntimeError(
179
+ "Refusing to use insecure cookies in prod/ci. "
180
+ + "Use an https server_address or set Cookie(secure=True) explicitly."
181
+ )
182
+ return secure
183
+
184
+
149
185
  def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
150
186
  host = _parse_host(server_address) or "localhost"
151
187
  opts: CORSOptions = {
pulse/env.py CHANGED
@@ -4,7 +4,7 @@ Centralized environment variable definitions and typed accessors for Pulse.
4
4
  Preferred usage:
5
5
 
6
6
  from pulse.env import env
7
- env.pulse_mode = "prod"
7
+ env.pulse_env = "prod"
8
8
  if env.running_cli:
9
9
  ...
10
10
 
@@ -20,7 +20,7 @@ from typing import Literal
20
20
  PulseEnv = Literal["dev", "ci", "prod"]
21
21
 
22
22
  # Keys
23
- ENV_PULSE_MODE = "PULSE_MODE"
23
+ ENV_PULSE_ENV = "PULSE_ENV"
24
24
  ENV_PULSE_APP_FILE = "PULSE_APP_FILE"
25
25
  ENV_PULSE_APP_DIR = "PULSE_APP_DIR"
26
26
  ENV_PULSE_HOST = "PULSE_HOST"
@@ -42,14 +42,14 @@ class EnvVars:
42
42
 
43
43
  @property
44
44
  def pulse_env(self) -> PulseEnv:
45
- value = (self._get(ENV_PULSE_MODE) or "dev").lower()
45
+ value = (self._get(ENV_PULSE_ENV) or "dev").lower()
46
46
  if value not in ("dev", "ci", "prod"):
47
47
  value = "dev"
48
48
  return value
49
49
 
50
50
  @pulse_env.setter
51
51
  def pulse_env(self, value: PulseEnv) -> None:
52
- self._set(ENV_PULSE_MODE, value)
52
+ self._set(ENV_PULSE_ENV, value)
53
53
 
54
54
  # App file/dir
55
55
  @property
pulse/hooks/init.py CHANGED
@@ -11,6 +11,7 @@ from typing import Any, Literal, cast, override
11
11
 
12
12
  from pulse.helpers import getsourcecode
13
13
  from pulse.hooks.core import HookState, hooks
14
+ from pulse.transpiler.errors import TranspileError
14
15
 
15
16
  # Storage keyed by (code object, lineno) of the `with ps.init()` call site.
16
17
  _init_hook = hooks.create("init_storage", lambda: InitState())
@@ -312,6 +313,10 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
312
313
  """Rewrite `with ps.init()` blocks in the provided function, if present."""
313
314
 
314
315
  source = textwrap.dedent(getsourcecode(func)) # raises immediately if missing
316
+ try:
317
+ source_start_line = inspect.getsourcelines(func)[1]
318
+ except (OSError, TypeError):
319
+ source_start_line = None
315
320
 
316
321
  if "init" not in source: # quick prefilter, allow alias detection later
317
322
  return func
@@ -320,6 +325,7 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
320
325
 
321
326
  init_names, init_modules = _resolve_init_bindings(func)
322
327
 
328
+ target_def: ast.FunctionDef | ast.AsyncFunctionDef | None = None
323
329
  # Remove decorators so the re-exec'd function isn't double-wrapped.
324
330
  for node in ast.walk(tree):
325
331
  if (
@@ -327,14 +333,59 @@ def rewrite_init_blocks(func: Callable[..., Any]) -> Callable[..., Any]:
327
333
  and node.name == func.__name__
328
334
  ):
329
335
  node.decorator_list = []
336
+ target_def = node
337
+
338
+ if target_def is None:
339
+ return func
330
340
 
331
341
  if not _contains_ps_init(tree, init_names, init_modules):
332
342
  return func
333
343
 
334
- if _has_disallowed_control_flow(tree, init_names, init_modules):
335
- raise RuntimeError(
336
- "ps.init blocks cannot contain control flow (if/for/while/try/with/match)"
337
- )
344
+ init_items = _find_init_items(target_def.body, init_names, init_modules)
345
+ if len(init_items) > 1:
346
+ try:
347
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
348
+ except (TypeError, OSError):
349
+ filename = None
350
+ raise TranspileError(
351
+ "ps.init may only be used once per component render",
352
+ node=init_items[1].context_expr,
353
+ source=source,
354
+ filename=filename,
355
+ func_name=func.__name__,
356
+ source_start_line=source_start_line,
357
+ ) from None
358
+
359
+ if init_items and init_items[0].optional_vars is not None:
360
+ try:
361
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
362
+ except (TypeError, OSError):
363
+ filename = None
364
+ raise TranspileError(
365
+ "ps.init does not support 'as' bindings",
366
+ node=init_items[0].optional_vars,
367
+ source=source,
368
+ filename=filename,
369
+ func_name=func.__name__,
370
+ source_start_line=source_start_line,
371
+ ) from None
372
+
373
+ disallowed = _find_disallowed_control_flow(
374
+ target_def.body, init_names, init_modules
375
+ )
376
+ if disallowed is not None:
377
+ try:
378
+ filename = inspect.getsourcefile(func) or inspect.getfile(func)
379
+ except (TypeError, OSError):
380
+ filename = None
381
+ raise TranspileError(
382
+ "ps.init blocks cannot contain control flow (if/for/while/try/with/match)",
383
+ node=disallowed,
384
+ source=source,
385
+ filename=filename,
386
+ func_name=func.__name__,
387
+ source_start_line=source_start_line,
388
+ ) from None
338
389
 
339
390
  rewriter: ast.NodeTransformer
340
391
  if _CAN_USE_CPYTHON:
@@ -373,19 +424,128 @@ def _contains_ps_init(
373
424
  return checker.contains_init(tree)
374
425
 
375
426
 
376
- def _has_disallowed_control_flow(
377
- tree: ast.AST, init_names: set[str], init_modules: set[str]
378
- ) -> bool:
379
- disallowed = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.Match)
427
+ def _find_disallowed_control_flow(
428
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
429
+ ) -> ast.stmt | None:
430
+ disallowed: tuple[type[ast.AST], ...] = (
431
+ ast.If,
432
+ ast.For,
433
+ ast.AsyncFor,
434
+ ast.While,
435
+ ast.Try,
436
+ ast.With,
437
+ ast.AsyncWith,
438
+ ast.Match,
439
+ )
380
440
  checker = _InitCallChecker(init_names, init_modules)
381
- for node in ast.walk(tree):
382
- if isinstance(node, ast.With):
441
+
442
+ class _Finder(ast.NodeVisitor):
443
+ found: ast.stmt | None
444
+
445
+ def __init__(self) -> None:
446
+ self.found = None
447
+
448
+ @override
449
+ def visit(self, node: ast.AST) -> Any: # type: ignore[override]
450
+ if self.found is not None:
451
+ return None
452
+ if isinstance(node, disallowed):
453
+ self.found = cast(ast.stmt, node)
454
+ return None
455
+ return super().visit(node)
456
+
457
+ @override
458
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
459
+ return None
460
+
461
+ @override
462
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
463
+ return None
464
+
465
+ @override
466
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
467
+ return None
468
+
469
+ finder = _Finder()
470
+
471
+ class _WithFinder(ast.NodeVisitor):
472
+ @override
473
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
383
474
  first = node.items[0] if node.items else None
384
475
  if first and checker.is_init_call(first.context_expr):
385
- continue
386
- if isinstance(node, disallowed):
387
- return True
388
- return False
476
+ for stmt in node.body:
477
+ finder.visit(stmt)
478
+ if finder.found is not None:
479
+ return None
480
+ self.generic_visit(node)
481
+
482
+ @override
483
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
484
+ first = node.items[0] if node.items else None
485
+ if first and checker.is_init_call(first.context_expr):
486
+ for stmt in node.body:
487
+ finder.visit(stmt)
488
+ if finder.found is not None:
489
+ return None
490
+ self.generic_visit(node)
491
+
492
+ @override
493
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
494
+ return None
495
+
496
+ @override
497
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
498
+ return None
499
+
500
+ @override
501
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
502
+ return None
503
+
504
+ with_finder = _WithFinder()
505
+ for stmt in body:
506
+ with_finder.visit(stmt)
507
+ if finder.found is not None:
508
+ return finder.found
509
+ return None
510
+
511
+
512
+ def _find_init_items(
513
+ body: Sequence[ast.stmt], init_names: set[str], init_modules: set[str]
514
+ ) -> list[ast.withitem]:
515
+ checker = _InitCallChecker(init_names, init_modules)
516
+ items: list[ast.withitem] = []
517
+
518
+ class _Finder(ast.NodeVisitor):
519
+ @override
520
+ def visit_With(self, node: ast.With) -> Any: # type: ignore[override]
521
+ first = node.items[0] if node.items else None
522
+ if first and checker.is_init_call(first.context_expr):
523
+ items.append(first)
524
+ self.generic_visit(node)
525
+
526
+ @override
527
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> Any: # type: ignore[override]
528
+ first = node.items[0] if node.items else None
529
+ if first and checker.is_init_call(first.context_expr):
530
+ items.append(first)
531
+ self.generic_visit(node)
532
+
533
+ @override
534
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
535
+ return None
536
+
537
+ @override
538
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
539
+ return None
540
+
541
+ @override
542
+ def visit_ClassDef(self, node: ast.ClassDef) -> Any:
543
+ return None
544
+
545
+ finder = _Finder()
546
+ for stmt in body:
547
+ finder.visit(stmt)
548
+ return items
389
549
 
390
550
 
391
551
  class _InitCallChecker:
pulse/hooks/state.py ADDED
@@ -0,0 +1,105 @@
1
+ from collections.abc import Callable
2
+ from typing import TypeVar, override
3
+
4
+ from pulse.hooks.core import HookMetadata, HookState, hooks
5
+ from pulse.state import State
6
+
7
+ S = TypeVar("S", bound=State)
8
+
9
+
10
+ class StateHookState(HookState):
11
+ __slots__ = ("instances", "called_keys") # pyright: ignore[reportUnannotatedClassAttribute]
12
+ instances: dict[str, State]
13
+ called_keys: set[str]
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+ self.instances = {}
18
+ self.called_keys = set()
19
+
20
+ @override
21
+ def on_render_start(self, render_cycle: int) -> None:
22
+ super().on_render_start(render_cycle)
23
+ self.called_keys.clear()
24
+
25
+ def get_or_create_state(self, key: str, arg: State | Callable[[], State]) -> State:
26
+ if key in self.called_keys:
27
+ raise RuntimeError(
28
+ f"`pulse.state` can only be called once per component render with key='{key}'"
29
+ )
30
+ self.called_keys.add(key)
31
+
32
+ existing = self.instances.get(key)
33
+ if existing is not None:
34
+ # Dispose any State instances passed directly as args that aren't being used
35
+ if isinstance(arg, State) and arg is not existing:
36
+ try:
37
+ if not arg.__disposed__:
38
+ arg.dispose()
39
+ except RuntimeError:
40
+ # Already disposed, ignore
41
+ pass
42
+ return existing
43
+
44
+ # Create new state
45
+ instance = _instantiate_state(arg)
46
+ self.instances[key] = instance
47
+ return instance
48
+
49
+ @override
50
+ def dispose(self) -> None:
51
+ for instance in self.instances.values():
52
+ try:
53
+ if not instance.__disposed__:
54
+ instance.dispose()
55
+ except RuntimeError:
56
+ # Already disposed, ignore
57
+ pass
58
+ self.instances.clear()
59
+
60
+
61
+ def _instantiate_state(arg: State | Callable[[], State]) -> State:
62
+ instance = arg() if callable(arg) else arg
63
+ if not isinstance(instance, State):
64
+ raise TypeError(
65
+ "`pulse.state` expects a State instance or a callable returning a State instance"
66
+ )
67
+ return instance
68
+
69
+
70
+ def _state_factory():
71
+ return StateHookState()
72
+
73
+
74
+ _state_hook = hooks.create(
75
+ "pulse:core.state",
76
+ _state_factory,
77
+ metadata=HookMetadata(
78
+ owner="pulse.core",
79
+ description="Internal storage for pulse.state hook",
80
+ ),
81
+ )
82
+
83
+
84
+ def state(key: str, arg: S | Callable[[], S]) -> S:
85
+ """Get or create a state instance associated with the given key.
86
+
87
+ Args:
88
+ key: A unique string key identifying this state within the component.
89
+ arg: A State instance or a callable that returns a State instance.
90
+
91
+ Returns:
92
+ The state instance (same instance on subsequent renders with the same key).
93
+
94
+ Raises:
95
+ ValueError: If key is empty.
96
+ RuntimeError: If called more than once per render with the same key.
97
+ TypeError: If arg is not a State or callable returning a State.
98
+ """
99
+ if not key:
100
+ raise ValueError("state() requires a non-empty string key")
101
+ hook_state = _state_hook()
102
+ return hook_state.get_or_create_state(key, arg) # pyright: ignore[reportReturnType]
103
+
104
+
105
+ __all__ = ["state", "StateHookState"]
pulse/js/__init__.py CHANGED
@@ -30,21 +30,23 @@ import importlib as _importlib
30
30
  from typing import Any as _Any
31
31
  from typing import NoReturn as _NoReturn
32
32
 
33
- from pulse.transpiler.builtins import obj as obj
33
+ from pulse.js.obj import obj as obj
34
+ from pulse.transpiler.nodes import EXPR_REGISTRY as _EXPR_REGISTRY
34
35
  from pulse.transpiler.nodes import UNDEFINED as _UNDEFINED
35
- from pulse.transpiler.nodes import Identifier as _Identifier
36
36
 
37
- # Namespace modules that resolve to Identifier
38
- _MODULE_EXPORTS_IDENTIFIER: dict[str, str] = {
37
+ # Namespace modules - return JsModule from registry (handles both builtins and external)
38
+ _MODULE_EXPORTS_NAMESPACE: dict[str, str] = {
39
39
  "JSON": "pulse.js.json",
40
40
  "Math": "pulse.js.math",
41
+ "React": "pulse.js.react",
42
+ "ReactDOM": "pulse.js.react_dom",
41
43
  "console": "pulse.js.console",
42
44
  "window": "pulse.js.window",
43
45
  "document": "pulse.js.document",
44
46
  "navigator": "pulse.js.navigator",
45
47
  }
46
48
 
47
- # Regular modules that resolve via getattr
49
+ # Class modules - return via getattr to get Class wrapper (emits `new ...`)
48
50
  _MODULE_EXPORTS_ATTRIBUTE: dict[str, str] = {
49
51
  "Array": "pulse.js.array",
50
52
  "Date": "pulse.js.date",
@@ -92,10 +94,11 @@ def __getattr__(name: str) -> _Any:
92
94
  if name in _export_cache:
93
95
  return _export_cache[name]
94
96
 
95
- # Check which dict contains the name
96
- if name in _MODULE_EXPORTS_IDENTIFIER:
97
- module = _importlib.import_module(_MODULE_EXPORTS_IDENTIFIER[name])
98
- export = _Identifier(name)
97
+ # Namespace modules: return JsModule (handles attribute access via transpile_getattr)
98
+ if name in _MODULE_EXPORTS_NAMESPACE:
99
+ module = _importlib.import_module(_MODULE_EXPORTS_NAMESPACE[name])
100
+ export = _EXPR_REGISTRY[id(module)]
101
+ # Class modules: return Class wrapper via getattr (emits `new ...()`)
99
102
  elif name in _MODULE_EXPORTS_ATTRIBUTE:
100
103
  module = _importlib.import_module(_MODULE_EXPORTS_ATTRIBUTE[name])
101
104
  export = getattr(module, name)