@@ -196,10 +196,19 @@ class ModuleReloader:
196196 """Reload changed Python modules on device and trigger a re-render.
197197
198198 Designed to be invoked from device-side glue when a hot-reload
199- push completes. The class itself holds no state; all methods are
200- 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.
201207 """
202208
209+ _last_reloaded_version : Optional [str ] = None
210+ _reload_lock = threading .Lock ()
211+
203212 @staticmethod
204213 def reload_module (module_name : str ) -> bool :
205214 """Reload a single module by its dotted name.
@@ -254,6 +263,139 @@ def reload_modules(module_names: Sequence[str]) -> List[str]:
254263 reloaded .append (module_name )
255264 return reloaded
256265
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+
257399 @staticmethod
258400 def file_to_module (file_path : str , base_dir : str = "" ) -> Optional [str ]:
259401 """Convert a file path to a dotted module name.
@@ -491,5 +633,13 @@ def reload_from_manifest(
491633 files = manifest .get ("files" , [])
492634 modules = ModuleReloader .modules_from_files (files if isinstance (files , list ) else [])
493635
494- ModuleReloader .reload_screen (screen_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
495645 return version
0 commit comments