diff --git a/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoController.java b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoController.java
new file mode 100644
index 00000000..ee0c10d9
--- /dev/null
+++ b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoController.java
@@ -0,0 +1,45 @@
+package mybookstore.handoff;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.servlet.ModelAndView;
+import tools.dynamia.navigation.NavigationManager;
+
+import java.io.IOException;
+
+/**
+ * Real-world navigation hand-off cases for manual/automated verification of
+ * {@code NavigationManagerSession} and its request-scope filter:
+ *
+ * - {@code /demo/handoff/redirect} — records a page + callback, then redirects (like a ZK command
+ * that calls {@code setPageLater} and {@code sendRedirect}). The target desktop must open Invoices (not the default page).
+ * - {@code /demo/handoff/desktop} — builds a ZK desktop without choosing a page itself, so it
+ * can only show what was handed off by a previous request (or the default page if nothing was).
+ * - {@code /demo/handoff/login} — see {@link HandOffDemoLoginFilter}.
+ * - {@code /demo/handoff/status} — how many queued callbacks have run.
+ *
+ */
+@Controller
+public class HandOffDemoController {
+
+ @GetMapping("/demo/handoff/redirect")
+ public void redirect(HttpServletResponse response) throws IOException {
+ NavigationManager.setPageLater("library/invoices");
+ NavigationManager.runLater(HandOffDemoState.CALLBACKS_RUN::incrementAndGet);
+ response.sendRedirect("/demo/handoff/desktop");
+ }
+
+ @GetMapping("/demo/handoff/desktop")
+ public ModelAndView desktop() {
+ return new ModelAndView("embed");
+ }
+
+ @GetMapping(value = "/demo/handoff/status", produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public String status() {
+ return "{\"callbacksRun\":" + HandOffDemoState.CALLBACKS_RUN.get() + "}";
+ }
+}
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoLoginFilter.java b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoLoginFilter.java
new file mode 100644
index 00000000..4d9e6827
--- /dev/null
+++ b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoLoginFilter.java
@@ -0,0 +1,39 @@
+package mybookstore.handoff;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import tools.dynamia.navigation.NavigationManager;
+
+import java.io.IOException;
+
+/**
+ * Simulates what a real login does in an app that uses Spring Security: a listener reacting to the
+ * authentication success ({@code LoginListener.onLoginSuccess}) calls {@code setPageLater}/{@code runLater}
+ * inside the security filter chain (order -100, before any controller) and the request ends with an
+ * HTTP redirect. Both things must work: the navigation scope must already be bound at that point, and the
+ * pending intent must survive the redirect.
+ *
+ * Try: {@code GET /demo/handoff/login} (redirects to {@code /demo/handoff/desktop}, which must open Customers).
+ */
+@Component
+@Order(-100)
+public class HandOffDemoLoginFilter extends OncePerRequestFilter {
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ return !"/demo/handoff/login".equals(request.getRequestURI());
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ NavigationManager.setPageLater("library/customers");
+ NavigationManager.runLater(HandOffDemoState.CALLBACKS_RUN::incrementAndGet);
+ response.sendRedirect("/demo/handoff/desktop");
+ }
+}
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoState.java b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoState.java
new file mode 100644
index 00000000..daffc42a
--- /dev/null
+++ b/examples/demo-zk-books/src/main/java/mybookstore/handoff/HandOffDemoState.java
@@ -0,0 +1,15 @@
+package mybookstore.handoff;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Counts how many {@code NavigationManager.runLater} callbacks queued by the hand-off demo endpoints
+ * actually ran, so the flow can be verified from the outside (see {@link HandOffDemoController}).
+ */
+final class HandOffDemoState {
+
+ static final AtomicInteger CALLBACKS_RUN = new AtomicInteger();
+
+ private HandOffDemoState() {
+ }
+}
diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/BaseNavigationManager.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/BaseNavigationManager.java
index 3817b891..8c5632d4 100644
--- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/BaseNavigationManager.java
+++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/BaseNavigationManager.java
@@ -26,6 +26,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
/**
*
@@ -81,6 +82,7 @@ public abstract class BaseNavigationManager implements Serializable, NavigationM
private NavigationBuilder currentNavigationBuilder;
private Map currentPageParams;
+ private final String id;
/**
* Creates a new BaseNavigationManager with the given {@link ModuleContainer}.
@@ -91,6 +93,22 @@ public BaseNavigationManager(ModuleContainer container) {
this.logger = new SLF4JLoggingService(BaseNavigationManager.class);
this.attributes = new HashMap<>();
this.container = container;
+ this.id = UUID.randomUUID().toString();
+ var registry = NavigationManagerRegistry.getInstance();
+ if (registry != null) {
+ registry.register(this);
+ }
+ }
+
+ /**
+ * Returns a stable, framework-agnostic identifier for this instance, generated once at
+ * construction time.
+ *
+ * @return an opaque, stable id for this instance
+ */
+ @Override
+ public String getId() {
+ return id;
}
/**
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..abe7b936 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,15 @@ 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 a {@link ScopedValue}: this must be called within the same
+ * scope 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, with the scope established by
+ * a request-lifecycle filter around the whole request). If the request ends (e.g. with an HTTP
+ * redirect) before any desktop consumed the value, the filter hands it off through the HTTP
+ * session to the next request. Throws {@link java.util.NoSuchElementException} if no scope is
+ * currently bound.
*
* @param page
*/
@@ -114,7 +122,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 scope requirement.
*
* @param page
* @param params
@@ -132,7 +141,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 scope requirement — this must be called within the
+ * same scope that will build the target {@link NavigationManager}.
*
* @param callback
*/
@@ -331,4 +342,19 @@ static void runLater(Callback callback) {
* @param navigationBuilder the navigation builder to set
*/
void setCurrentNavigationBuilder(NavigationBuilder navigationBuilder);
+
+ /**
+ * Returns a stable identifier for this {@link NavigationManager} instance, unique within the
+ * current user session, framework-agnostic (it never references a UI-specific concept such as a
+ * ZK {@code Desktop} id).
+ *
+ * Used to correlate a manager instance with "which tab/iframe/desktop" it belongs to when more
+ * than one instance is active in the same session at once (e.g. several {@code