From c379e662e196060960b9f07d0a7771e57e6feaad Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 28 Jul 2026 17:47:17 +0200 Subject: [PATCH] fix: a recreated module widget must invalidate the stale module A fresh module widget can carry a name that is already in the page global registry: solara closes the per-kernel module widgets on a hot reload (a trait update on a closed widget never reaches the browser) and creates new ones. Only change:code/url/dependencies invalidated the registry, so consumers rendered after the reload could resolve the previous module - permanently, since nothing re-renders them when the new module is provided later. Whether that happened depended on which of the two won the race, so hot reload silently kept serving the old module on loaded machines. Co-Authored-By: Claude Fable 5 --- src/widget.tsx | 10 +++++++--- tests/ui/module_hot_reload_test.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/widget.tsx b/src/widget.tsx index 1507cd0..d3714b5 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -73,9 +73,9 @@ function provideModule(moduleName: string, module: any) { } function invalidateModule(moduleName: string) { - // next requestModule waits for a fresh provideModule (hot reload); called - // synchronously on change:code so consumers created after the update never - // see the stale module + // Next requestModule waits for a fresh provideModule. This is called + // synchronously for both replacement models and trait updates so consumers + // created during hot reload never see the stale module. delete modules[moduleName]; delete moduleFunctions[moduleName]; } @@ -315,6 +315,10 @@ export class Module extends WidgetModel { } initialize(attributes: any, options: any): void { super.initialize(attributes, options); + // A kernel restart replaces the old Module model instead of updating its + // code trait. Invalidate synchronously so consumers created alongside the + // replacement wait for this model rather than using the cached module. + invalidateModule(this.get("name")); this.addModule(); // hot reload: re-import when the kernel ships new module code this.on("change:code change:url change:dependencies", () => { diff --git a/tests/ui/module_hot_reload_test.py b/tests/ui/module_hot_reload_test.py index e9a159b..05b0825 100644 --- a/tests/ui/module_hot_reload_test.py +++ b/tests/ui/module_hot_reload_test.py @@ -24,3 +24,24 @@ def test_module_code_hot_reload(solara_test, page_session: playwright.sync_api.P ipyreact.define_module("hot-reload-module", code_template % 2) display(ipyreact.ValueWidget(_module="hot-reload-module", _type="Label")) page_session.locator(".hot-widget >> text=version 2").wait_for() + + +def test_module_recreated_widget_hot_reload(solara_test, page_session: playwright.sync_api.Page): + # A *new* module widget for a name that is already registered must win + # over the module the registry still holds. Solara closes the per-kernel + # module widgets on a hot reload (a trait update on a closed widget never + # reaches the browser) and creates fresh ones, so without invalidating, + # consumers rendered afterwards keep resolving the previous module. + from solara.server import esm, kernel_context + + ipyreact.define_module("recreate-module", code_template % 1) + display(ipyreact.ValueWidget(_module="recreate-module", _type="Label")) + page_session.locator(".hot-widget >> text=version 1").wait_for() + + # what context.restart() does to the module widgets on a hot reload + kernel_id = kernel_context.get_current_context().id + esm._modules_added_per_kernel[kernel_id]["recreate-module"].close() + + ipyreact.define_module("recreate-module", code_template % 2) + display(ipyreact.ValueWidget(_module="recreate-module", _type="Label")) + page_session.locator(".hot-widget >> text=version 2").wait_for()