pythonnative 0.12.0__py3-none-any.whl → 0.13.1__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.
pythonnative/__init__.py CHANGED
@@ -41,7 +41,7 @@ Example:
41
41
  ```
42
42
  """
43
43
 
44
- __version__ = "0.12.0"
44
+ __version__ = "0.13.1"
45
45
 
46
46
  from .alerts import Alert
47
47
  from .animated import Animated, AnimatedValue
@@ -98,8 +98,8 @@ from .navigation import (
98
98
  use_focus_effect,
99
99
  use_route,
100
100
  )
101
- from .page import create_page
102
101
  from .platform import Platform
102
+ from .screen import create_screen
103
103
  from .style import StyleSheet, ThemeContext
104
104
 
105
105
  __all__ = [
@@ -130,7 +130,7 @@ __all__ = [
130
130
  "WebView",
131
131
  # Core
132
132
  "Element",
133
- "create_page",
133
+ "create_screen",
134
134
  # Hooks
135
135
  "batch_updates",
136
136
  "component",
pythonnative/cli/pn.py CHANGED
@@ -32,9 +32,9 @@ from typing import Any, Dict, List, Optional
32
32
  def init_project(args: argparse.Namespace) -> None:
33
33
  """Scaffold a new PythonNative project in the current directory.
34
34
 
35
- Creates `app/main_page.py`, `pythonnative.json`,
36
- `requirements.txt`, and `.gitignore`. Refuses to overwrite
37
- existing files unless `--force` is passed.
35
+ Creates `app/main.py`, `pythonnative.json`, `requirements.txt`,
36
+ and `.gitignore`. Refuses to overwrite existing files unless
37
+ `--force` is passed.
38
38
 
39
39
  Args:
40
40
  args: The parsed argparse namespace. Recognized attributes:
@@ -68,30 +68,55 @@ def init_project(args: argparse.Namespace) -> None:
68
68
 
69
69
  os.makedirs(app_dir, exist_ok=True)
70
70
 
71
- main_page_py = os.path.join(app_dir, "main_page.py")
72
- if not os.path.exists(main_page_py) or args.force:
73
- with open(main_page_py, "w", encoding="utf-8") as f:
71
+ main_py = os.path.join(app_dir, "main.py")
72
+ if not os.path.exists(main_py) or args.force:
73
+ with open(main_py, "w", encoding="utf-8") as f:
74
74
  f.write("""import pythonnative as pn
75
75
 
76
+ Stack = pn.create_stack_navigator()
77
+
76
78
 
77
79
  @pn.component
78
- def MainPage():
80
+ def HomeScreen():
79
81
  count, set_count = pn.use_state(0)
82
+ nav = pn.use_navigation()
80
83
  return pn.ScrollView(
81
84
  pn.Column(
82
85
  pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}),
83
86
  pn.Text(f"Tapped {count} times"),
84
87
  pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
88
+ pn.Button("Open detail", on_click=lambda: nav.navigate("Detail", {"count": count})),
85
89
  style={"spacing": 12, "padding": 16, "align_items": "stretch"},
86
90
  )
87
91
  )
92
+
93
+
94
+ @pn.component
95
+ def DetailScreen():
96
+ nav = pn.use_navigation()
97
+ params = pn.use_route()
98
+ return pn.Column(
99
+ pn.Text(f"Detail: count was {params.get('count', 0)}", style={"font_size": 20}),
100
+ pn.Button("Back", on_click=nav.go_back),
101
+ style={"spacing": 12, "padding": 16},
102
+ )
103
+
104
+
105
+ @pn.component
106
+ def App():
107
+ return pn.NavigationContainer(
108
+ Stack.Navigator(
109
+ Stack.Screen("Home", component=HomeScreen, options={"title": "Home"}),
110
+ Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}),
111
+ )
112
+ )
88
113
  """)
89
114
 
90
115
  # Create config
91
116
  config = {
92
117
  "name": project_name,
93
118
  "appId": "com.example." + project_name.replace(" ", "").lower(),
94
- "entryPoint": "app/main_page.py",
119
+ "entryPoint": "app/main.py",
95
120
  "pythonVersion": "3.11",
96
121
  "ios": {},
97
122
  "android": {},
@@ -319,7 +344,7 @@ ANDROID_LOGCAT_FILTERS: list[str] = [
319
344
  "python.stdout:V",
320
345
  "python.stderr:V",
321
346
  "MainActivity:V",
322
- "PageFragment:V",
347
+ "ScreenFragment:V",
323
348
  "Navigator:V",
324
349
  "PythonNative:V",
325
350
  "AndroidRuntime:E",
pythonnative/hooks.py CHANGED
@@ -511,13 +511,13 @@ def use_window_dimensions() -> Dict[str, float]:
511
511
  """Return the current viewport size and re-render when it changes.
512
512
 
513
513
  Equivalent to React Native's ``useWindowDimensions``. The values
514
- are pushed by the page host whenever the platform reports a new
514
+ are pushed by the screen host whenever the platform reports a new
515
515
  size (initial layout, rotation, multitasking split-view).
516
516
 
517
517
  Returns:
518
518
  A dict with ``"width"`` and ``"height"`` floats in layout
519
519
  units (pt on iOS, dp on Android). Both are ``0.0`` until the
520
- page host has run its first layout pass.
520
+ screen host has run its first layout pass.
521
521
 
522
522
  Raises:
523
523
  RuntimeError: If called outside a `@component` function.
@@ -717,8 +717,13 @@ _NavigationContext: Context = create_context(None)
717
717
  class NavigationHandle:
718
718
  """Handle returned by [`use_navigation`][pythonnative.use_navigation].
719
719
 
720
- Wraps the host's push/pop primitives so screens can navigate without
721
- knowing the underlying native navigation stack.
720
+ Wraps the host's push/pop primitives so screens can navigate
721
+ without knowing the underlying native navigation stack. The
722
+ typical user-facing surface is the declarative handle returned by
723
+ a [`Stack`][pythonnative.create_stack_navigator] — this class is
724
+ the lower-level fallback used when no navigator is rendered (and
725
+ as the bridge that declarative navigators delegate to when they
726
+ need to push real native screens).
722
727
 
723
728
  Example:
724
729
  ```python
@@ -729,7 +734,7 @@ class NavigationHandle:
729
734
  nav = pn.use_navigation()
730
735
  return pn.Button(
731
736
  "Open Detail",
732
- on_click=lambda: nav.navigate(DetailScreen, params={"id": 42}),
737
+ on_click=lambda: nav.navigate("Detail", {"id": 42}),
733
738
  )
734
739
  ```
735
740
  """
@@ -737,16 +742,20 @@ class NavigationHandle:
737
742
  def __init__(self, host: Any) -> None:
738
743
  self._host = host
739
744
 
740
- def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None:
741
- """Push `page` onto the navigation stack.
745
+ def navigate(self, component: Any, params: Optional[Dict[str, Any]] = None) -> None:
746
+ """Push ``component`` onto the navigation stack.
742
747
 
743
748
  Args:
744
- page: Either a `@component` function or a dotted Python
745
- path (e.g., `"app.detail.DetailScreen"`).
749
+ component: A ``@component`` function or a dotted Python
750
+ path (e.g. ``"app.detail.DetailScreen"``). When a
751
+ Stack navigator is the root of the app, prefer the
752
+ declarative ``nav.navigate("Detail", params)`` form
753
+ returned by ``use_navigation()`` (it pushes by route
754
+ name and the host re-uses its own ``App`` component).
746
755
  params: Optional dict of arguments serialized into the
747
756
  target screen.
748
757
  """
749
- self._host._push(page, params)
758
+ self._host._push(component, params)
750
759
 
751
760
  def go_back(self) -> None:
752
761
  """Pop the current screen and return to the previous one."""
@@ -771,13 +780,13 @@ def use_navigation() -> NavigationHandle:
771
780
 
772
781
  Raises:
773
782
  RuntimeError: If called outside a component rendered via
774
- [`create_page`][pythonnative.create_page].
783
+ [`create_screen`][pythonnative.create_screen].
775
784
  """
776
785
  handle = use_context(_NavigationContext)
777
786
  if handle is None:
778
787
  raise RuntimeError(
779
- "use_navigation() called outside a PythonNative page. "
780
- "Ensure your component is rendered via create_page()."
788
+ "use_navigation() called outside a PythonNative screen. "
789
+ "Ensure your component is rendered via create_screen()."
781
790
  )
782
791
  return handle
783
792
 
@@ -3,16 +3,30 @@
3
3
  Two cooperating pieces:
4
4
 
5
5
  - **Host-side**: [`FileWatcher`][pythonnative.hot_reload.FileWatcher]
6
- polls the developer's `app/` directory for `.py` changes and
7
- triggers a callback (typically `adb push` on Android or a
8
- `simctl` file copy on iOS).
6
+ polls the developer's ``app/`` directory for ``.py`` changes and
7
+ triggers a callback (typically ``adb push`` on Android or a
8
+ ``simctl`` file copy on iOS).
9
9
  - **Device-side**:
10
10
  [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] reloads
11
- changed Python modules using `importlib.reload` and asks the page
12
- host to re-render the current tree.
11
+ changed Python modules using ``importlib`` and asks the screen
12
+ host to re-render its current tree.
13
+
14
+ Two strategies share the device-side surface:
15
+
16
+ - **Fast Refresh** (default): after reloading the changed modules
17
+ the reconciler tree is walked and every component function whose
18
+ module was reloaded is swapped in place. Hook state, navigation
19
+ state, and even scroll positions survive because the underlying
20
+ ``VNode`` objects are reused — the next render simply calls the
21
+ new function bodies through the old slots.
22
+ - **Full remount**: when the in-place swap fails (e.g. the new
23
+ module raised at import time, or a render exception bubbled out
24
+ while running the new function), the host falls back to building
25
+ a brand-new reconciler tree. State is lost but the app keeps
26
+ running.
13
27
 
14
28
  Example:
15
- Integrated into `pn run --hot-reload`:
29
+ Integrated into ``pn run --hot-reload``:
16
30
 
17
31
  ```python
18
32
  from pythonnative.hot_reload import FileWatcher
@@ -33,7 +47,7 @@ import os
33
47
  import sys
34
48
  import threading
35
49
  import time
36
- from typing import Any, Callable, Dict, List, Optional, Sequence
50
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set
37
51
 
38
52
  DEV_ROOT_DIR = "pythonnative_dev"
39
53
  """Name of the writable on-device directory that shadows bundled app code."""
@@ -46,7 +60,7 @@ def configure_dev_environment(writable_root: str) -> str:
46
60
  """Create and prioritize the writable hot-reload source overlay.
47
61
 
48
62
  The returned directory is inserted at the front of `sys.path`, so a
49
- pushed `app/main_page.py` shadows the copy bundled into the native
63
+ pushed `app/main.py` shadows the copy bundled into the native
50
64
  application. Templates call this before importing user code.
51
65
 
52
66
  Args:
@@ -182,16 +196,25 @@ class ModuleReloader:
182
196
  """Reload changed Python modules on device and trigger a re-render.
183
197
 
184
198
  Designed to be invoked from device-side glue when a hot-reload
185
- push completes. The class itself holds no state; all methods are
186
- static.
199
+ push completes. All public methods are static; the class holds a
200
+ single piece of process-wide state — the manifest version that
201
+ has most recently been applied to ``sys.modules`` — so that
202
+ multiple screen hosts polling the same manifest do not each
203
+ re-execute the user-app modules. The first host to see a new
204
+ version pays the ``reload_modules`` cost; subsequent hosts on the
205
+ same version refresh only their own reconciler tree against the
206
+ already-fresh modules.
187
207
  """
188
208
 
209
+ _last_reloaded_version: Optional[str] = None
210
+ _reload_lock = threading.Lock()
211
+
189
212
  @staticmethod
190
213
  def reload_module(module_name: str) -> bool:
191
214
  """Reload a single module by its dotted name.
192
215
 
193
216
  Args:
194
- module_name: Dotted module name (e.g., `"app.main_page"`).
217
+ module_name: Dotted module name (e.g., `"app.main"`).
195
218
 
196
219
  Returns:
197
220
  `True` if the module imported successfully from the current
@@ -240,6 +263,139 @@ class ModuleReloader:
240
263
  reloaded.append(module_name)
241
264
  return reloaded
242
265
 
266
+ @staticmethod
267
+ def reload_modules_for_version(
268
+ module_names: Sequence[str],
269
+ version: Optional[str],
270
+ ) -> List[str]:
271
+ """Reload ``module_names`` for ``version``, deduping across hosts.
272
+
273
+ Each native screen host on iOS / Android runs its own poll
274
+ loop and would otherwise call
275
+ [`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
276
+ independently for the same manifest version. That re-executes
277
+ every user-app module N times (once per host) per file change,
278
+ producing N different generations of the same function objects
279
+ in ``sys.modules`` and leaving each host's reconciler tree
280
+ pointing at a different generation. Beyond the wasted work,
281
+ the inconsistent state has been observed to crash UIKit on iOS
282
+ with ``CALayerInvalidGeometry`` (NaN values fed into ``setFrame_:``
283
+ during the interleaved renders).
284
+
285
+ This helper serializes on
286
+ [`_reload_lock`][pythonnative.hot_reload.ModuleReloader] and uses
287
+ [`_last_reloaded_version`][pythonnative.hot_reload.ModuleReloader]
288
+ to ensure only the *first* host to see a given ``version``
289
+ actually re-executes the modules. Subsequent hosts on the same
290
+ version get back the already-fresh entries from ``sys.modules``
291
+ so their own
292
+ [`refresh_in_place`][pythonnative.hot_reload.ModuleReloader.refresh_in_place]
293
+ pass can still rewrite their tree against the same generation.
294
+
295
+ Args:
296
+ module_names: Dotted module names to reload.
297
+ version: Manifest version this reload is processing. When
298
+ ``None`` (e.g. tests calling reload directly) the call
299
+ falls back to the unconditional
300
+ [`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
301
+ behavior.
302
+
303
+ Returns:
304
+ The list of module names that are currently fresh in
305
+ ``sys.modules`` — either freshly reloaded by this call, or
306
+ already reloaded by an earlier host for the same version.
307
+ """
308
+ with ModuleReloader._reload_lock:
309
+ if version is not None and version == ModuleReloader._last_reloaded_version:
310
+ return [name for name in module_names if name in sys.modules]
311
+ reloaded = ModuleReloader.reload_modules(module_names)
312
+ if reloaded and version is not None:
313
+ ModuleReloader._last_reloaded_version = version
314
+ return reloaded
315
+
316
+ @staticmethod
317
+ def expand_reload_targets(changed_modules: Sequence[str], component_path: str) -> List[str]:
318
+ """Expand a manifest of changed modules into the full reload order.
319
+
320
+ When a user edits ``app/screens/home.py``, only that file is in
321
+ the manifest. But the entry-point module ``app.main`` has
322
+ bindings like ``from app.screens.home import HomeScreen`` that
323
+ need to be re-evaluated against the freshly-loaded
324
+ ``app.screens.home``; likewise other user-app modules may carry
325
+ transitive bindings (e.g. through a shared ``app/theme.py``)
326
+ that go stale if only the changed file is reloaded.
327
+
328
+ This helper computes the full ordered reload list:
329
+
330
+ 1. Explicitly changed modules first (in the order given), so
331
+ their fresh source replaces the cached version in
332
+ ``sys.modules`` before any dependent modules re-execute.
333
+ 2. All other currently-imported modules under the entry-point's
334
+ top-level package, deepest first. The depth heuristic biases
335
+ toward leaves so re-executing a screen file picks up the
336
+ newest shared utilities before the file that imports it does.
337
+ 3. The entry-point module itself, last, so its
338
+ ``from ... import`` bindings rebind against everything that
339
+ was refreshed in steps 1 and 2.
340
+
341
+ Modules outside the entry-point's top-level package
342
+ (``pythonnative.*``, stdlib, third-party) are never included;
343
+ framework code is not reloaded.
344
+
345
+ Args:
346
+ changed_modules: Modules reported as changed by the host
347
+ file-watcher (already in dotted form).
348
+ component_path: The host's entry-point identifier, either a
349
+ module path (``"app.main"``) or a dotted attribute path
350
+ (``"app.main.RootScreen"``).
351
+
352
+ Returns:
353
+ The ordered list of modules to feed to
354
+ [`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules].
355
+ """
356
+ entry_module: Optional[str] = None
357
+ if component_path in sys.modules:
358
+ entry_module = component_path
359
+ elif "." in component_path:
360
+ parent = component_path.rsplit(".", 1)[0]
361
+ if parent in sys.modules:
362
+ entry_module = parent
363
+
364
+ app_prefix: Optional[str] = None
365
+ if entry_module:
366
+ app_prefix = entry_module.split(".")[0]
367
+ else:
368
+ for m in changed_modules:
369
+ if m:
370
+ app_prefix = m.split(".")[0]
371
+ break
372
+
373
+ app_modules: Set[str] = set()
374
+ if app_prefix:
375
+ for name in list(sys.modules):
376
+ if name == app_prefix or name.startswith(app_prefix + "."):
377
+ app_modules.add(name)
378
+
379
+ ordered: List[str] = []
380
+ seen: Set[str] = set()
381
+ for m in changed_modules:
382
+ if m and m not in seen:
383
+ ordered.append(m)
384
+ seen.add(m)
385
+
386
+ others = [m for m in app_modules if m not in seen and m != entry_module]
387
+ others.sort(key=lambda m: (-m.count("."), m))
388
+ for m in others:
389
+ ordered.append(m)
390
+ seen.add(m)
391
+
392
+ if entry_module:
393
+ if entry_module in seen:
394
+ ordered.remove(entry_module)
395
+ ordered.append(entry_module)
396
+
397
+ return ordered
398
+
243
399
  @staticmethod
244
400
  def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
245
401
  """Convert a file path to a dotted module name.
@@ -250,7 +406,7 @@ class ModuleReloader:
250
406
  If empty, `file_path` is treated as already relative.
251
407
 
252
408
  Returns:
253
- The dotted module name (e.g., `"app.pages.home"`), or
409
+ The dotted module name (e.g., `"app.screens.home"`), or
254
410
  `None` for an empty path.
255
411
  """
256
412
  rel = os.path.relpath(file_path, base_dir) if base_dir else file_path
@@ -273,28 +429,180 @@ class ModuleReloader:
273
429
  return modules
274
430
 
275
431
  @staticmethod
276
- def reload_page(page_instance: Any, module_names: Optional[Sequence[str]] = None) -> None:
277
- """Force a page re-render after a module reload.
432
+ def reload_screen(screen_instance: Any, module_names: Optional[Sequence[str]] = None) -> None:
433
+ """Force a screen re-render after a module reload.
278
434
 
279
435
  Args:
280
- page_instance: An `_AppHost` instance (or duck-typed
436
+ screen_instance: A `_ScreenHost` instance (or duck-typed
281
437
  equivalent) that exposes a `_reconciler` attribute.
282
438
  module_names: Optional modules that changed. Reload-aware
283
- page hosts use this to refresh imports before re-render.
439
+ screen hosts use this to refresh imports before re-render.
284
440
  """
285
- reload_fn = getattr(page_instance, "reload", None)
441
+ reload_fn = getattr(screen_instance, "reload", None)
286
442
  if callable(reload_fn):
287
443
  reload_fn(list(module_names or []))
288
444
  return
289
445
 
290
- from .page import _request_render
446
+ from .screen import _request_render
291
447
 
292
- if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None:
293
- _request_render(page_instance)
448
+ if hasattr(screen_instance, "_reconciler") and screen_instance._reconciler is not None:
449
+ _request_render(screen_instance)
450
+
451
+ @staticmethod
452
+ def find_replacement_function(old_fn: Any) -> Optional[Any]:
453
+ """Locate a function's post-reload counterpart by qualname.
454
+
455
+ Functions decorated with [`component`][pythonnative.component]
456
+ store the user's original function on the wrapper's
457
+ ``__wrapped__`` attribute and forward ``__module__`` /
458
+ ``__qualname__`` so that the reconciler's stored
459
+ ``element.type`` (the unwrapped function) still has the
460
+ information needed to re-resolve after a module reload.
461
+
462
+ Args:
463
+ old_fn: The function captured in an
464
+ [`Element`][pythonnative.Element]'s ``type`` slot.
465
+
466
+ Returns:
467
+ The reloaded module's matching function, ``None`` if no
468
+ replacement was found, or the original function itself
469
+ when the module has not been reloaded (so callers can
470
+ skip the swap).
471
+ """
472
+ module_name = getattr(old_fn, "__module__", None)
473
+ qualname = getattr(old_fn, "__qualname__", None) or getattr(old_fn, "__name__", None)
474
+ if not module_name or not qualname:
475
+ return None
476
+ if "<locals>" in qualname:
477
+ return None # nested functions are not addressable from the module surface
478
+
479
+ module = sys.modules.get(module_name)
480
+ if module is None:
481
+ return None
482
+
483
+ obj: Any = module
484
+ for part in qualname.split("."):
485
+ obj = getattr(obj, part, None)
486
+ if obj is None:
487
+ return None
488
+
489
+ if getattr(obj, "_pn_component", False):
490
+ obj = getattr(obj, "__wrapped__", obj)
491
+
492
+ if obj is old_fn:
493
+ return None
494
+ return obj
495
+
496
+ @staticmethod
497
+ def build_replacement_map(reconciler: Any, reloaded_modules: Iterable[str]) -> Dict[Any, Any]:
498
+ """Compute ``{old_function: new_function}`` for one tree.
499
+
500
+ The reconciler's stored tree references the *pre-reload*
501
+ component functions through ``VNode.element.type``. This
502
+ method walks the tree, collects every callable type whose
503
+ ``__module__`` was just reloaded, and asks
504
+ [`find_replacement_function`][pythonnative.hot_reload.ModuleReloader.find_replacement_function]
505
+ for its successor.
506
+
507
+ Args:
508
+ reconciler: The reconciler whose
509
+ ``_tree`` should be inspected.
510
+ reloaded_modules: Set of module names that were just
511
+ reloaded (only callables from these modules are
512
+ considered).
513
+
514
+ Returns:
515
+ A mapping suitable for passing to
516
+ [`swap_components_in_tree`][pythonnative.hot_reload.ModuleReloader.swap_components_in_tree].
517
+ """
518
+ modules: Set[str] = {m for m in reloaded_modules if m}
519
+ if not modules or reconciler is None or getattr(reconciler, "_tree", None) is None:
520
+ return {}
521
+
522
+ seen: Set[int] = set()
523
+ mapping: Dict[Any, Any] = {}
524
+
525
+ def visit(vnode: Any) -> None:
526
+ if vnode is None:
527
+ return
528
+ elem = getattr(vnode, "element", None)
529
+ if elem is not None and callable(elem.type):
530
+ fn = elem.type
531
+ fn_id = id(fn)
532
+ if fn_id not in seen:
533
+ seen.add(fn_id)
534
+ if getattr(fn, "__module__", None) in modules:
535
+ replacement = ModuleReloader.find_replacement_function(fn)
536
+ if replacement is not None and replacement is not fn:
537
+ mapping[fn] = replacement
538
+ for child in getattr(vnode, "children", []) or []:
539
+ visit(child)
540
+
541
+ visit(reconciler._tree)
542
+ return mapping
543
+
544
+ @staticmethod
545
+ def swap_components_in_tree(reconciler: Any, replacement_map: Dict[Any, Any]) -> int:
546
+ """Apply a ``{old: new}`` map to every node in the reconciler tree.
547
+
548
+ Mutates ``vnode.element.type`` directly so the NEXT diff sees
549
+ identical types and reuses VNodes (preserving hook state).
550
+ Pending ``Element`` trees stored on ``vnode._rendered`` are
551
+ rewritten too because the reconciler reads from them when
552
+ comparing keys across renders.
553
+
554
+ Returns:
555
+ The number of element type references that were rewritten.
556
+ """
557
+ if not replacement_map or reconciler is None or getattr(reconciler, "_tree", None) is None:
558
+ return 0
559
+
560
+ rewrites = 0
561
+
562
+ def rewrite_element_tree(element: Any) -> None:
563
+ nonlocal rewrites
564
+ if element is None:
565
+ return
566
+ new_type = replacement_map.get(element.type)
567
+ if new_type is not None:
568
+ element.type = new_type
569
+ rewrites += 1
570
+ for child in element.children or []:
571
+ rewrite_element_tree(child)
572
+
573
+ def visit(vnode: Any) -> None:
574
+ if vnode is None:
575
+ return
576
+ if getattr(vnode, "element", None) is not None:
577
+ rewrite_element_tree(vnode.element)
578
+ rendered = getattr(vnode, "_rendered", None)
579
+ if rendered is not None:
580
+ rewrite_element_tree(rendered)
581
+ for child in getattr(vnode, "children", []) or []:
582
+ visit(child)
583
+
584
+ visit(reconciler._tree)
585
+ return rewrites
586
+
587
+ @staticmethod
588
+ def refresh_in_place(reconciler: Any, reloaded_modules: Iterable[str]) -> bool:
589
+ """Try a state-preserving Fast Refresh for one reconciler.
590
+
591
+ Returns:
592
+ ``True`` if any component function was replaced (callers
593
+ should then trigger a re-render). ``False`` means the
594
+ tree already references the latest functions (or has no
595
+ nodes from the reloaded modules at all).
596
+ """
597
+ replacement_map = ModuleReloader.build_replacement_map(reconciler, reloaded_modules)
598
+ if not replacement_map:
599
+ return False
600
+ rewrites = ModuleReloader.swap_components_in_tree(reconciler, replacement_map)
601
+ return rewrites > 0
294
602
 
295
603
  @staticmethod
296
604
  def reload_from_manifest(
297
- page_instance: Any,
605
+ screen_instance: Any,
298
606
  manifest_path: str,
299
607
  *,
300
608
  last_version: Optional[str] = None,
@@ -302,9 +610,9 @@ class ModuleReloader:
302
610
  """Apply a reload manifest if it is newer than `last_version`.
303
611
 
304
612
  Args:
305
- page_instance: Page host to refresh.
613
+ screen_instance: Screen host to refresh.
306
614
  manifest_path: JSON manifest written by the CLI.
307
- last_version: Version already applied by this page host.
615
+ last_version: Version already applied by this screen host.
308
616
 
309
617
  Returns:
310
618
  The manifest version after applying, or `last_version` when
@@ -325,5 +633,13 @@ class ModuleReloader:
325
633
  files = manifest.get("files", [])
326
634
  modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else [])
327
635
 
328
- ModuleReloader.reload_page(page_instance, [str(module) for module in modules])
636
+ # Stash the version on the host so `_reload_host` can dedupe
637
+ # `reload_modules` across multiple hosts polling the same
638
+ # manifest. See `reload_modules_for_version`.
639
+ previous_pending = getattr(screen_instance, "_hot_reload_pending_version", None)
640
+ try:
641
+ screen_instance._hot_reload_pending_version = version
642
+ ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
643
+ finally:
644
+ screen_instance._hot_reload_pending_version = previous_pending
329
645
  return version