Skip to content

feat(navigation): support concurrent multi-tab/iframe ZK navigation (ScopedValue + cross-request hand-off) - #112

Merged
marioserrano09 merged 6 commits into
mainfrom
feat/navigation-multi-desktop-support
Sep 30, 2026
Merged

marioserrano09 merged 6 commits into
mainfrom
feat/navigation-multi-desktop-support

Conversation

@marioserrano09

Copy link
Copy Markdown
Contributor

Refs #107, #108 (Epic: concurrent multi-tab / multi-iframe ZK navigation)

What

NavigationManagerSession is no longer a session-scoped bean with a single shared slot. Each request gets its own instance bound through a ScopedValue by NavigationManagerSessionScopeFilter, so several ZK desktops (iframes / tabs) bootstrapping at once in the same HTTP session no longer overwrite each other's pending page or runLater queue.

Also in this branch: stable NavigationManager.getId() and a session-level NavigationManagerRegistry (unregistered on ZK desktop cleanup).

Last commits: cross-request hand-off

Per-request scope alone breaks callers that record an intent and then end the request without forwarding into a desktop — e.g. a LoginListener calling setPageLater/runLater followed by a redirect, or a ZK command doing setPageLater + sendRedirect. These are used by downstream apps (found while reviewing dynamia-erp).

  • The filter parks any still-pending intent in the HTTP session at the end of the request and restores it into the next request; the first desktop bootstrap consumes it. Parked state is transient (queued callbacks are not serializable) and never shared across sessions.
  • Filter is @Order(HIGHEST_PRECEDENCE + 10): the scope must be bound before Spring Security fires AuthenticationSuccessEvent login listeners.
  • NavigationManagerSession: hasPendingState(), absorb(), and an onPending hook so the session exists before a redirect commits the response (bug found with the demo app: first request without a session lost the intent).

Behavior notes

  • NavigationManagerSession.getInstance() throws NoSuchElementException outside a bound scope (fail fast; background jobs must not call setPageLater/runLater).
  • Residual race: an intent parked for the next request is taken by the first request that arrives, not necessarily the intended one (e.g. two iframes opened right after a login). Strictly better than the old shared slot; eliminating it needs a per-URL token, ruled out in Epic: Support concurrent multi-tab / multi-iframe ZK navigation #107.
  • Serializable on NavigationManagerSession is now vestigial; left untouched to keep this PR focused.

Verification

  • Unit tests: NavigationManagerSessionTest (10), NavigationManagerSessionScopeFilterTest (9, incl. concurrency, cross-request hand-off, no-session-yet redirect, order < Spring Security).
  • examples/demo-zk-books (new /demo/handoff/* endpoints), run from the packaged jar:
    • login-style filter at order -100 + redirect from a fresh session → desktop opens the handed-off page, callbacks run;
    • controller setPageLater + redirect → opens the handed-off page (not the default one);
    • consumed once (second desktop gets the default page);
    • 10 concurrent sessions doing the login flow → all land on the right page, 10 callbacks run;
    • 45 concurrent /page-embed/* requests on one session → no cross-contamination; no NoSuchElementException / dropped-state warnings in logs.

Not verified

  • A real Spring Security login (the demo has none; the filter at -100 simulates the position). Needs a run against dynamia-erp.

Follow-ups

  • dynamia-erp: EmbedSessionLockFilter becomes unnecessary once this ships.
  • Document the hand-off contract in docs/backend/.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXVz2G6v1fqFdQjRSh9XZF

marioserrano09 and others added 6 commits September 29, 2026 12:36
…f 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 <noreply@anthropic.com>
…o NavigationManager

Adds NavigationManager#getId(), a framework-agnostic identifier
generated once per instance (BaseNavigationManager), so a manager can
be correlated to "which tab/iframe/desktop" it belongs to when several
instances are active in the same session at once.

Adds NavigationManagerRegistry (@scope("session")), tracking every
active instance keyed by id. BaseNavigationManager self-registers on
construction. ZKNavigationManager wires best-effort unregistration via
a ZK DesktopCleanup listener when a desktop (tab/iframe) is destroyed;
correctness doesn't depend on this firing, since the registry itself
is session-scoped and is discarded along with the whole session.

Enables a future host shell embedding multiple ZK iframes to enumerate
or target a specific open tab/iframe's navigation instead of only
reaching "the current one" implicitly resolved from the executing
request/desktop.

Refs #107, #109, #110

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e id, and registry

- NavigationManagerSessionTest: same-thread get/set roundtrip, updateNavManager
  consuming/clearing pending state, runLater/executeQueue ordering, clear()
  behavior, and the key property motivating the redesign: a page set on one
  thread must not be visible from another (concurrent tab/iframe isolation).
- BaseNavigationManagerIdTest: getId() is non-null, stable, and unique per
  instance.
- NavigationManagerRegistryTest: register/unregister/find/getActiveInstances,
  including BaseNavigationManager's self-registration when a registry is
  reachable via Containers, and that construction doesn't fail without one.
- NavigationManagerSessionCleanupFilterTest: clears the thread-local after a
  successful chain and after the chain throws; the pending page stays visible
  to the chain itself.

TestNavigationManager is a minimal concrete BaseNavigationManager used only
by these tests (BaseNavigationManager itself is abstract; the only real
implementation, ZKNavigationManager, requires a ZK desktop).

Verified with `mvn test` on platform/core/navigation and platform/core/web
(-am): 21 new tests, all green, no regressions in the reactor.

Refs #107, #108, #109, #110

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tionManagerSession

Java 25 (this project's target) finalized ScopedValue (JEP 506), the
structured-concurrency-friendly replacement for ThreadLocal in exactly
this kind of "bind a value for the dynamic extent of a request" use
case, with better behavior under virtual threads and no manual cleanup
step (the binding is torn down automatically when the bound run/call
returns, even on exception).

NavigationManagerSession.SCOPE is now a ScopedValue<NavigationManagerSession>;
getInstance() throws NoSuchElementException if called outside a bound
scope, instead of the old ThreadLocal's implicit lazy-create. Renamed
NavigationManagerSessionCleanupFilter -> NavigationManagerSessionScopeFilter,
since its role changed from "clear afterward" to "bind the scope around
the whole request" via ScopedValue.where(...).call(...) - the filter
was only just introduced on this branch, so this is not an API break.

Updated tests accordingly, including a concurrency test proving two
threads bootstrapping concurrently (simulating two iframes/tabs) never
see each other's pending page - the actual property this whole change
exists to guarantee.

Verified with `mvn clean test` on platform/core/navigation and
platform/core/web (-am): all green, no regressions.

Refs #107, #108

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
With NavigationManagerSession bound per request (ScopedValue), an intent
recorded right before an HTTP redirect (e.g. a LoginListener calling
setPageLater/runLater, or a ZK command doing setPageLater + sendRedirect)
was lost when the scope ended.

NavigationManagerSessionScopeFilter now parks any still-pending intent in
the HTTP session at the end of the request and restores it into the next
request's instance, where the first desktop bootstrap consumes it. The
parked state is transient (queued callbacks are not serializable) and
never shared across sessions.

- Filter is ordered before the Spring Security chain (@order
  HIGHEST_PRECEDENCE + 10) so the scope is already bound when login
  listeners run on AuthenticationSuccessEvent.
- NavigationManagerSession: hasPendingState(), absorb(other) and an
  onPending hook. The hook makes the filter create the HTTP session when
  the intent is recorded, before a redirect commits the response (found
  with the demo app: a first request with no session lost the intent).
- Javadoc of NavigationManager.setPageLater no longer forbids redirects.

Refs #107, #108

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXVz2G6v1fqFdQjRSh9XZF
Real-world cases for verifying NavigationManagerSession and its filter:
a filter at Spring Security's order that records setPageLater/runLater
and redirects (simulated login), a controller doing the same, a bare
desktop that only shows what was handed off, and a callbacks counter.
Endpoints live under /demo/handoff/*.

Refs #107, #108

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXVz2G6v1fqFdQjRSh9XZF
@marioserrano09
marioserrano09 merged commit cd0e6dd into main Sep 30, 2026
1 check passed
@marioserrano09 marioserrano09 mentioned this pull request Sep 30, 2026
1 task done
marioserrano09 added a commit that referenced this pull request Oct 1, 2026
* chore: bump version to 26.10.0

Includes navigation multi-desktop support (#112), inline ZK embed (#114)
and localized navigation labels (#116).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* chore(cli): bump cli.properties versions to 26.10.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant