From 32ed21bb209008aad2e19772af6a244275cf4c98 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Tue, 29 Sep 2026 12:36:04 -0500 Subject: [PATCH 1/6] fix(navigation): make NavigationManagerSession thread-local instead of session-scoped PageNavigationController/PageEmbedController render their view via a servlet forward, so setPageLater()/runLater() and the ZK desktop that consumes them (ZKNavigationManager.init(), doAfterCompose()) always run on the same thread within the same request. A session-scoped bean was therefore both unnecessary and unsafe: concurrent tabs/iframes in the same HTTP session would race on the single shared page/params slot. Replaces the @Scope("session") bean with a ThreadLocal holder (same public API) and adds a request-completion filter to clear it, so pooled threads don't retain a stale instance across requests. Refs #107, #108 Co-Authored-By: Claude Sonnet 5 --- .../dynamia/navigation/NavigationManager.java | 14 +++-- .../navigation/NavigationManagerSession.java | 50 ++++++++++++++--- ...NavigationManagerSessionCleanupFilter.java | 53 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/NavigationManagerSessionCleanupFilter.java diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManager.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManager.java index cd8b8030..67581556 100644 --- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManager.java +++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManager.java @@ -105,7 +105,12 @@ static NavigationManager getCurrent() { } /** - * Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded + * Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded. + *

+ * {@link NavigationManagerSession} is thread-local: this must be called on the same thread that + * will forward into the target ZK desktop (e.g. from a controller rendering an {@code index}/ + * {@code embed} ZUL view via a server-side forward), never before an HTTP redirect — a redirect + * is a new request on a possibly different thread, so the value would not be picked up. * * @param page */ @@ -114,7 +119,8 @@ static void setPageLater(Page page) { } /** - * Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded + * Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded. + * See {@link #setPageLater(Page)} for the thread-affinity requirement. * * @param page * @param params @@ -132,7 +138,9 @@ static void setPageLater(String path, Map params) { } /** - * Delegate callback to run when {@link NavigationManager} are builded. Its store a Queue using {@link NavigationManagerSession} + * Delegate callback to run when {@link NavigationManager} are builded. Its store a Queue using {@link NavigationManagerSession}. + * See {@link #setPageLater(Page)} for the thread-affinity requirement — this must be called on + * the same thread that will build the target {@link NavigationManager}. * * @param callback */ diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManagerSession.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManagerSession.java index 85184286..b68aa047 100644 --- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManagerSession.java +++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationManagerSession.java @@ -1,26 +1,64 @@ package tools.dynamia.navigation; -import org.springframework.context.annotation.Scope; import tools.dynamia.commons.Callback; -import tools.dynamia.integration.Containers; -import tools.dynamia.integration.sterotypes.Component; import java.io.Serializable; import java.util.LinkedList; import java.util.Map; import java.util.Queue; -@Component -@Scope("session") +/** + * Holds a navigation intent (a {@link Page} plus optional params, and/or queued {@link Callback}s) + * so it can be picked up by the {@link NavigationManager} of a ZK desktop that is about to be built. + *

+ * This is a {@link ThreadLocal} holder, not a session-scoped bean: the only supported flow is a + * server-side forward (e.g. {@code RequestDispatcher.forward()}, as used by + * {@code PageNavigationController}/{@code PageEmbedController} to render an {@code index}/{@code embed} + * ZUL view) where the code calling {@link #setPage(Page, Map)}/{@link #runLater(Callback)} and the + * ZK desktop bootstrap that consumes it ({@code ZKNavigationManager.init()}, + * {@code ZKNavigationComposer.doAfterCompose()}) run on the very same thread, within the very same + * HTTP request. + *

+ *

+ * Do not call {@link #setPage(Page, Map)}/{@link #runLater(Callback)} before an HTTP + * redirect expecting a later request to pick it up — a redirect is a new request, possibly + * served by a different thread, and the thread-local value won't be there. If that use case ever + * arises it needs a different, explicit hand-off mechanism, not this class. + *

+ *

+ * Using a thread-local (instead of the session-scoped bean this class used to be) is what makes it + * safe for multiple ZK desktops to bootstrap concurrently in the same HTTP session — e.g. several + * {@code