pulse-framework 0.1.71__py3-none-any.whl → 0.1.73__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 +19 -4
- pulse/app.py +27 -24
- pulse/cli/cmd.py +1 -1
- pulse/cli/folder_lock.py +25 -6
- pulse/cli/processes.py +2 -0
- pulse/codegen/templates/layout.py +3 -1
- pulse/debounce.py +79 -0
- pulse/decorators.py +4 -3
- pulse/hooks/effects.py +20 -6
- pulse/hooks/runtime.py +25 -8
- pulse/hooks/setup.py +6 -10
- pulse/hooks/stable.py +5 -9
- pulse/hooks/state.py +4 -8
- pulse/proxy.py +719 -185
- pulse/queries/common.py +17 -5
- pulse/queries/infinite_query.py +14 -3
- pulse/queries/mutation.py +2 -1
- pulse/queries/query.py +4 -2
- pulse/render_session.py +7 -4
- pulse/renderer.py +30 -2
- pulse/routing.py +19 -5
- pulse/serializer.py +38 -19
- pulse/state/__init__.py +1 -0
- pulse/state/property.py +218 -0
- pulse/state/query_param.py +538 -0
- pulse/{state.py → state/state.py} +66 -220
- pulse/transpiler/nodes.py +26 -2
- pulse/transpiler/transpiler.py +86 -5
- pulse/transpiler/vdom.py +1 -1
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/METADATA +4 -4
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/RECORD +33 -29
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.71.dist-info → pulse_framework-0.1.73.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query parameter bindings for State properties.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date, datetime, timedelta, timezone
|
|
10
|
+
from types import UnionType
|
|
11
|
+
from typing import (
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
Any,
|
|
14
|
+
Generic,
|
|
15
|
+
TypeAlias,
|
|
16
|
+
TypeVar,
|
|
17
|
+
cast,
|
|
18
|
+
get_args,
|
|
19
|
+
get_origin,
|
|
20
|
+
override,
|
|
21
|
+
)
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from pulse.context import PulseContext
|
|
25
|
+
from pulse.helpers import Disposable, values_equal
|
|
26
|
+
from pulse.reactive import Effect, Scope, Signal, Untrack
|
|
27
|
+
from pulse.reactive_extensions import reactive, unwrap
|
|
28
|
+
from pulse.state.property import InitializableProperty, StateProperty
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from pulse.render_session import RenderSession
|
|
34
|
+
from pulse.routing import RouteContext
|
|
35
|
+
from pulse.state.state import State
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
if sys.version_info >= (3, 12):
|
|
40
|
+
type QueryParam[T] = T
|
|
41
|
+
else:
|
|
42
|
+
QueryParam: TypeAlias = T
|
|
43
|
+
else:
|
|
44
|
+
|
|
45
|
+
class QueryParam(Generic[T]):
|
|
46
|
+
"""
|
|
47
|
+
Query parameter binding for State properties.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
q: QueryParam[str] = ""
|
|
51
|
+
page: QueryParam[int] = 1
|
|
52
|
+
|
|
53
|
+
At type-check time, QueryParam[T] is treated as T.
|
|
54
|
+
At runtime, QueryParam[T] is detected by StateMeta and converted to QueryParamProperty.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class QueryParamCodec:
|
|
62
|
+
kind: str
|
|
63
|
+
label: str
|
|
64
|
+
optional: bool = False
|
|
65
|
+
item: "QueryParamCodec | None" = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _query_param_warning(message: str) -> None:
|
|
69
|
+
warnings.warn(message, stacklevel=3)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _coerce_datetime(value: datetime, *, param: str) -> datetime:
|
|
73
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
74
|
+
_query_param_warning(
|
|
75
|
+
"[Pulse] QueryParam '" + param + "' received naive datetime; assuming UTC."
|
|
76
|
+
)
|
|
77
|
+
return value.replace(tzinfo=timezone.utc)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_bool(raw: str, *, param: str) -> bool:
|
|
82
|
+
normalized = raw.strip().lower()
|
|
83
|
+
if normalized in ("true", "1"):
|
|
84
|
+
return True
|
|
85
|
+
if normalized in ("false", "0"):
|
|
86
|
+
return False
|
|
87
|
+
raise ValueError(f"QueryParam '{param}' expected bool, got '{raw}'")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_date(raw: str, *, param: str) -> date:
|
|
91
|
+
try:
|
|
92
|
+
return date.fromisoformat(raw)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"QueryParam '{param}' expected date (YYYY-MM-DD), got '{raw}'"
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_datetime(raw: str, *, param: str) -> datetime:
|
|
100
|
+
value = raw
|
|
101
|
+
if value.endswith("Z") or value.endswith("z"):
|
|
102
|
+
value = value[:-1] + "+00:00"
|
|
103
|
+
try:
|
|
104
|
+
parsed = datetime.fromisoformat(value)
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"QueryParam '{param}' expected datetime (ISO 8601), got '{raw}'"
|
|
108
|
+
) from exc
|
|
109
|
+
return _coerce_datetime(parsed, param=param)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _serialize_datetime(value: datetime, *, param: str) -> str:
|
|
113
|
+
coerced = _coerce_datetime(value, param=param)
|
|
114
|
+
result = coerced.isoformat()
|
|
115
|
+
if coerced.utcoffset() == timedelta(0) and result.endswith("+00:00"):
|
|
116
|
+
return result[:-6] + "Z"
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _escape_list_item(value: str) -> str:
|
|
121
|
+
return value.replace("\\", "\\\\").replace(",", "\\,")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _split_list_items(raw: str, *, param: str) -> list[str]:
|
|
125
|
+
if raw == "":
|
|
126
|
+
return []
|
|
127
|
+
items: list[str] = []
|
|
128
|
+
buf: list[str] = []
|
|
129
|
+
escaping = False
|
|
130
|
+
for ch in raw:
|
|
131
|
+
if escaping:
|
|
132
|
+
if ch not in ("\\", ","):
|
|
133
|
+
raise ValueError(f"QueryParam '{param}' has invalid escape '\\{ch}'")
|
|
134
|
+
buf.append(ch)
|
|
135
|
+
escaping = False
|
|
136
|
+
continue
|
|
137
|
+
if ch == "\\":
|
|
138
|
+
escaping = True
|
|
139
|
+
continue
|
|
140
|
+
if ch == ",":
|
|
141
|
+
items.append("".join(buf))
|
|
142
|
+
buf = []
|
|
143
|
+
continue
|
|
144
|
+
buf.append(ch)
|
|
145
|
+
if escaping:
|
|
146
|
+
raise ValueError(f"QueryParam '{param}' has trailing escape '\\\\'")
|
|
147
|
+
items.append("".join(buf))
|
|
148
|
+
return items
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_union_origin(origin: Any) -> bool:
|
|
152
|
+
return origin is UnionType or (
|
|
153
|
+
getattr(origin, "__module__", "") == "typing"
|
|
154
|
+
and getattr(origin, "__qualname__", "") == "Union"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_query_param_codec(value_type: Any) -> QueryParamCodec:
|
|
159
|
+
origin = get_origin(value_type)
|
|
160
|
+
args = get_args(value_type)
|
|
161
|
+
if _is_union_origin(origin):
|
|
162
|
+
non_none = [arg for arg in args if arg is not type(None)]
|
|
163
|
+
if len(non_none) != len(args) - 1:
|
|
164
|
+
raise TypeError("QueryParam Optional types must include exactly one None")
|
|
165
|
+
if len(non_none) != 1:
|
|
166
|
+
raise TypeError("QueryParam Optional types must wrap a single type")
|
|
167
|
+
inner = _build_query_param_codec(non_none[0])
|
|
168
|
+
return QueryParamCodec(
|
|
169
|
+
kind=inner.kind,
|
|
170
|
+
label=inner.label,
|
|
171
|
+
optional=True,
|
|
172
|
+
item=inner.item,
|
|
173
|
+
)
|
|
174
|
+
if origin is list:
|
|
175
|
+
if len(args) != 1:
|
|
176
|
+
raise TypeError("QueryParam list types must specify an item type")
|
|
177
|
+
item_codec = _build_query_param_codec(args[0])
|
|
178
|
+
if item_codec.kind == "list":
|
|
179
|
+
raise TypeError("QueryParam list types cannot be nested")
|
|
180
|
+
return QueryParamCodec(
|
|
181
|
+
kind="list",
|
|
182
|
+
label=f"list[{item_codec.label}]",
|
|
183
|
+
item=item_codec,
|
|
184
|
+
)
|
|
185
|
+
if value_type is str:
|
|
186
|
+
return QueryParamCodec(kind="str", label="str")
|
|
187
|
+
if value_type is int:
|
|
188
|
+
return QueryParamCodec(kind="int", label="int")
|
|
189
|
+
if value_type is float:
|
|
190
|
+
return QueryParamCodec(kind="float", label="float")
|
|
191
|
+
if value_type is bool:
|
|
192
|
+
return QueryParamCodec(kind="bool", label="bool")
|
|
193
|
+
if value_type is date:
|
|
194
|
+
return QueryParamCodec(kind="date", label="date")
|
|
195
|
+
if value_type is datetime:
|
|
196
|
+
return QueryParamCodec(kind="datetime", label="datetime")
|
|
197
|
+
raise TypeError(f"Unsupported QueryParam type: {value_type!r}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _parse_query_param_scalar(raw: str, *, codec: QueryParamCodec, param: str) -> Any:
|
|
201
|
+
if raw == "" and codec.optional:
|
|
202
|
+
return None
|
|
203
|
+
if codec.kind == "str":
|
|
204
|
+
return raw
|
|
205
|
+
if codec.kind == "int":
|
|
206
|
+
try:
|
|
207
|
+
return int(raw)
|
|
208
|
+
except ValueError as exc:
|
|
209
|
+
raise ValueError(f"QueryParam '{param}' expected int, got '{raw}'") from exc
|
|
210
|
+
if codec.kind == "float":
|
|
211
|
+
try:
|
|
212
|
+
return float(raw)
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"QueryParam '{param}' expected float, got '{raw}'"
|
|
216
|
+
) from exc
|
|
217
|
+
if codec.kind == "bool":
|
|
218
|
+
return _parse_bool(raw, param=param)
|
|
219
|
+
if codec.kind == "date":
|
|
220
|
+
return _parse_date(raw, param=param)
|
|
221
|
+
if codec.kind == "datetime":
|
|
222
|
+
return _parse_datetime(raw, param=param)
|
|
223
|
+
raise TypeError(f"Unsupported QueryParam codec '{codec.kind}'")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_query_param_value(
|
|
227
|
+
raw: str | None,
|
|
228
|
+
*,
|
|
229
|
+
default: Any,
|
|
230
|
+
codec: QueryParamCodec,
|
|
231
|
+
param: str,
|
|
232
|
+
) -> Any:
|
|
233
|
+
if raw is None:
|
|
234
|
+
return default
|
|
235
|
+
if raw == "" and codec.optional:
|
|
236
|
+
return None
|
|
237
|
+
if codec.kind == "list":
|
|
238
|
+
assert codec.item is not None
|
|
239
|
+
items: list[Any] = []
|
|
240
|
+
for token in _split_list_items(raw, param=param):
|
|
241
|
+
if token == "" and codec.item.optional:
|
|
242
|
+
items.append(None)
|
|
243
|
+
continue
|
|
244
|
+
items.append(
|
|
245
|
+
_parse_query_param_scalar(token, codec=codec.item, param=param)
|
|
246
|
+
)
|
|
247
|
+
return reactive(items)
|
|
248
|
+
return _parse_query_param_scalar(raw, codec=codec, param=param)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _serialize_query_param_scalar(
|
|
252
|
+
value: Any, *, codec: QueryParamCodec, param: str
|
|
253
|
+
) -> str:
|
|
254
|
+
if codec.kind == "str":
|
|
255
|
+
if not isinstance(value, str):
|
|
256
|
+
raise TypeError(f"QueryParam '{param}' expected str, got {type(value)!r}")
|
|
257
|
+
return value
|
|
258
|
+
if codec.kind == "int":
|
|
259
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
260
|
+
raise TypeError(f"QueryParam '{param}' expected int, got {type(value)!r}")
|
|
261
|
+
return str(value)
|
|
262
|
+
if codec.kind == "float":
|
|
263
|
+
if not isinstance(value, float):
|
|
264
|
+
raise TypeError(f"QueryParam '{param}' expected float, got {type(value)!r}")
|
|
265
|
+
return str(value)
|
|
266
|
+
if codec.kind == "bool":
|
|
267
|
+
if not isinstance(value, bool):
|
|
268
|
+
raise TypeError(f"QueryParam '{param}' expected bool, got {type(value)!r}")
|
|
269
|
+
return "true" if value else "false"
|
|
270
|
+
if codec.kind == "date":
|
|
271
|
+
if not isinstance(value, date) or isinstance(value, datetime):
|
|
272
|
+
raise TypeError(f"QueryParam '{param}' expected date, got {type(value)!r}")
|
|
273
|
+
return value.isoformat()
|
|
274
|
+
if codec.kind == "datetime":
|
|
275
|
+
if not isinstance(value, datetime):
|
|
276
|
+
raise TypeError(
|
|
277
|
+
f"QueryParam '{param}' expected datetime, got {type(value)!r}"
|
|
278
|
+
)
|
|
279
|
+
return _serialize_datetime(value, param=param)
|
|
280
|
+
raise TypeError(f"Unsupported QueryParam codec '{codec.kind}'")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _serialize_query_param_value(
|
|
284
|
+
value: Any,
|
|
285
|
+
*,
|
|
286
|
+
default: Any,
|
|
287
|
+
codec: QueryParamCodec,
|
|
288
|
+
param: str,
|
|
289
|
+
) -> str | None:
|
|
290
|
+
if value is None:
|
|
291
|
+
return None
|
|
292
|
+
if values_equal(value, default):
|
|
293
|
+
return None
|
|
294
|
+
if codec.kind == "list":
|
|
295
|
+
if not isinstance(value, list):
|
|
296
|
+
raise TypeError(f"QueryParam '{param}' expected list, got {type(value)!r}")
|
|
297
|
+
assert codec.item is not None
|
|
298
|
+
items = cast(list[Any], value)
|
|
299
|
+
if len(items) == 0:
|
|
300
|
+
if values_equal(value, default):
|
|
301
|
+
return None
|
|
302
|
+
return ""
|
|
303
|
+
parts: list[str] = []
|
|
304
|
+
for item in items:
|
|
305
|
+
if item is None:
|
|
306
|
+
if codec.item.optional:
|
|
307
|
+
parts.append("")
|
|
308
|
+
continue
|
|
309
|
+
raise TypeError(f"QueryParam '{param}' list items cannot be None")
|
|
310
|
+
parts.append(
|
|
311
|
+
_escape_list_item(
|
|
312
|
+
_serialize_query_param_scalar(item, codec=codec.item, param=param)
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return ",".join(parts)
|
|
316
|
+
return _serialize_query_param_scalar(value, codec=codec, param=param)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def extract_query_param(annotation: Any) -> tuple[Any, bool]:
|
|
320
|
+
"""Extract the inner type from QueryParam[T] if present."""
|
|
321
|
+
origin = get_origin(annotation)
|
|
322
|
+
if origin is QueryParam:
|
|
323
|
+
args = get_args(annotation)
|
|
324
|
+
if len(args) != 1:
|
|
325
|
+
raise TypeError(
|
|
326
|
+
"QueryParam expects a single type argument (e.g. QueryParam[str])."
|
|
327
|
+
)
|
|
328
|
+
return args[0], True
|
|
329
|
+
return annotation, False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class QueryParamProperty(StateProperty, InitializableProperty):
|
|
333
|
+
value_type: Any
|
|
334
|
+
param_name: str
|
|
335
|
+
codec: QueryParamCodec
|
|
336
|
+
default_value: Any
|
|
337
|
+
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
name: str,
|
|
341
|
+
default: Any,
|
|
342
|
+
value_type: Any,
|
|
343
|
+
):
|
|
344
|
+
self.default_value = unwrap(default, untrack=True)
|
|
345
|
+
super().__init__(name, default)
|
|
346
|
+
self.value_type = value_type
|
|
347
|
+
self.param_name = name
|
|
348
|
+
self.codec = _build_query_param_codec(value_type)
|
|
349
|
+
|
|
350
|
+
@override
|
|
351
|
+
def __set_name__(self, owner: type[Any], name: str) -> None:
|
|
352
|
+
super().__set_name__(owner, name)
|
|
353
|
+
self.param_name = name
|
|
354
|
+
|
|
355
|
+
@override
|
|
356
|
+
def initialize(self, state: "State", name: str) -> None:
|
|
357
|
+
ctx = PulseContext.get()
|
|
358
|
+
if ctx.render is None or ctx.route is None:
|
|
359
|
+
raise RuntimeError(
|
|
360
|
+
"QueryParam properties require a route render context. Create the state inside a component render."
|
|
361
|
+
)
|
|
362
|
+
sync = ctx.route.query_param_sync
|
|
363
|
+
registration = sync.register(state, name, self)
|
|
364
|
+
setattr(state, f"_query_param_reg_{name}", registration)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class QueryParamBinding:
|
|
369
|
+
param: str
|
|
370
|
+
state: "State"
|
|
371
|
+
prop: QueryParamProperty
|
|
372
|
+
attr_name: str
|
|
373
|
+
|
|
374
|
+
def signal(self) -> Signal[Any]:
|
|
375
|
+
return self.prop.get_signal(self.state)
|
|
376
|
+
|
|
377
|
+
def default(self) -> Any:
|
|
378
|
+
return self.prop.default_value
|
|
379
|
+
|
|
380
|
+
def codec(self) -> QueryParamCodec:
|
|
381
|
+
return self.prop.codec
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class QueryParamRegistration(Disposable):
|
|
385
|
+
_sync: "QueryParamSync"
|
|
386
|
+
_param: str
|
|
387
|
+
|
|
388
|
+
def __init__(self, sync: "QueryParamSync", param: str) -> None:
|
|
389
|
+
self._sync = sync
|
|
390
|
+
self._param = param
|
|
391
|
+
|
|
392
|
+
@override
|
|
393
|
+
def dispose(self) -> None:
|
|
394
|
+
self._sync.unregister(self._param)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class QueryParamSync(Disposable):
|
|
398
|
+
route: "RouteContext"
|
|
399
|
+
render: "RenderSession"
|
|
400
|
+
_bindings: dict[str, QueryParamBinding]
|
|
401
|
+
_route_effect: Effect | None
|
|
402
|
+
_state_effect: Effect | None
|
|
403
|
+
|
|
404
|
+
def __init__(self, render: "RenderSession", route: "RouteContext") -> None:
|
|
405
|
+
self.render = render
|
|
406
|
+
self.route = route
|
|
407
|
+
self._bindings = {}
|
|
408
|
+
self._route_effect = None
|
|
409
|
+
self._state_effect = None
|
|
410
|
+
|
|
411
|
+
def register(
|
|
412
|
+
self, state: "State", attr_name: str, prop: QueryParamProperty
|
|
413
|
+
) -> QueryParamRegistration:
|
|
414
|
+
param = prop.param_name
|
|
415
|
+
if not param:
|
|
416
|
+
raise RuntimeError("QueryParam param name was not resolved")
|
|
417
|
+
if param in self._bindings:
|
|
418
|
+
raise ValueError(f"QueryParam '{param}' is already bound in this route")
|
|
419
|
+
binding = QueryParamBinding(
|
|
420
|
+
param=param,
|
|
421
|
+
state=state,
|
|
422
|
+
prop=prop,
|
|
423
|
+
attr_name=attr_name,
|
|
424
|
+
)
|
|
425
|
+
self._bindings[param] = binding
|
|
426
|
+
self._ensure_effects()
|
|
427
|
+
self._apply_route_to_binding(binding)
|
|
428
|
+
self._prime_effects()
|
|
429
|
+
return QueryParamRegistration(self, param)
|
|
430
|
+
|
|
431
|
+
def unregister(self, param: str) -> None:
|
|
432
|
+
binding = self._bindings.pop(param, None)
|
|
433
|
+
if binding is None:
|
|
434
|
+
return
|
|
435
|
+
if not self._bindings:
|
|
436
|
+
if self._route_effect:
|
|
437
|
+
self._route_effect.dispose()
|
|
438
|
+
self._route_effect = None
|
|
439
|
+
if self._state_effect:
|
|
440
|
+
self._state_effect.dispose()
|
|
441
|
+
self._state_effect = None
|
|
442
|
+
|
|
443
|
+
def _ensure_effects(self) -> None:
|
|
444
|
+
if self._route_effect is None or self._state_effect is None:
|
|
445
|
+
with Scope():
|
|
446
|
+
if self._route_effect is None:
|
|
447
|
+
self._route_effect = Effect(
|
|
448
|
+
self._sync_from_route,
|
|
449
|
+
name="QueryParamSync:route",
|
|
450
|
+
lazy=True,
|
|
451
|
+
)
|
|
452
|
+
if self._state_effect is None:
|
|
453
|
+
self._state_effect = Effect(
|
|
454
|
+
self._sync_to_route,
|
|
455
|
+
name="QueryParamSync:state",
|
|
456
|
+
lazy=True,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _prime_effects(self) -> None:
|
|
460
|
+
if self._route_effect:
|
|
461
|
+
self._route_effect.run()
|
|
462
|
+
if self._state_effect:
|
|
463
|
+
self._state_effect.run()
|
|
464
|
+
|
|
465
|
+
def _apply_route_to_binding(self, binding: QueryParamBinding) -> None:
|
|
466
|
+
query_params = self.route.queryParams
|
|
467
|
+
raw = query_params.get(binding.param)
|
|
468
|
+
parsed = _parse_query_param_value(
|
|
469
|
+
raw,
|
|
470
|
+
default=binding.default(),
|
|
471
|
+
codec=binding.codec(),
|
|
472
|
+
param=binding.param,
|
|
473
|
+
)
|
|
474
|
+
signal = binding.signal()
|
|
475
|
+
current = signal.value
|
|
476
|
+
if values_equal(current, parsed):
|
|
477
|
+
return
|
|
478
|
+
binding.prop.__set__(binding.state, parsed)
|
|
479
|
+
|
|
480
|
+
def _sync_from_route(self) -> None:
|
|
481
|
+
_ = self.route.queryParams
|
|
482
|
+
for binding in self._bindings.values():
|
|
483
|
+
self._apply_route_to_binding(binding)
|
|
484
|
+
|
|
485
|
+
def _sync_to_route(self) -> None:
|
|
486
|
+
with Untrack():
|
|
487
|
+
info = self.route.info
|
|
488
|
+
raw_params = info["queryParams"]
|
|
489
|
+
current_params = dict(cast(Mapping[str, str], raw_params))
|
|
490
|
+
pathname = info["pathname"]
|
|
491
|
+
hash_frag = info["hash"]
|
|
492
|
+
query_params = dict(current_params)
|
|
493
|
+
for binding in self._bindings.values():
|
|
494
|
+
signal = binding.signal()
|
|
495
|
+
value = signal.read()
|
|
496
|
+
codec = binding.codec()
|
|
497
|
+
if codec.kind == "list" and value is not None:
|
|
498
|
+
value = unwrap(value)
|
|
499
|
+
serialized = _serialize_query_param_value(
|
|
500
|
+
value,
|
|
501
|
+
default=binding.default(),
|
|
502
|
+
codec=codec,
|
|
503
|
+
param=binding.param,
|
|
504
|
+
)
|
|
505
|
+
if serialized is None:
|
|
506
|
+
query_params.pop(binding.param, None)
|
|
507
|
+
else:
|
|
508
|
+
query_params[binding.param] = serialized
|
|
509
|
+
|
|
510
|
+
if query_params == current_params:
|
|
511
|
+
return
|
|
512
|
+
path = pathname
|
|
513
|
+
query = urlencode(query_params)
|
|
514
|
+
if query:
|
|
515
|
+
path += "?" + query
|
|
516
|
+
if hash_frag:
|
|
517
|
+
if hash_frag.startswith("#"):
|
|
518
|
+
path += hash_frag
|
|
519
|
+
else:
|
|
520
|
+
path += "#" + hash_frag
|
|
521
|
+
self.render.send(
|
|
522
|
+
{
|
|
523
|
+
"type": "navigate_to",
|
|
524
|
+
"path": path,
|
|
525
|
+
"replace": True,
|
|
526
|
+
"hard": False,
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
@override
|
|
531
|
+
def dispose(self) -> None:
|
|
532
|
+
if self._route_effect:
|
|
533
|
+
self._route_effect.dispose()
|
|
534
|
+
self._route_effect = None
|
|
535
|
+
if self._state_effect:
|
|
536
|
+
self._state_effect.dispose()
|
|
537
|
+
self._state_effect = None
|
|
538
|
+
self._bindings.clear()
|