Skip to content

Commit ae31992

Browse files
authored
feat: add UI router state signal (#24234)
Expose UI.routerStateSignal() as a read-only Signal<RouterState> holding the current Location, RouteParameters, active chain and navigation target class. The signal is updated atomically alongside AfterNavigationEvent dispatch, so reactive consumers and listeners observe the same state. Lets components observe the active route via Signal.effect instead of registering an AfterNavigationListener and manually fetching the initial state from UIInternals on attach. Fine-grained projections (current location, route parameters, leaf view) are derivable with Signal.map; two-way URL/parameter binding is intentionally left for a follow-up so its API can be designed independently.
1 parent 55905d7 commit ae31992

10 files changed

Lines changed: 619 additions & 0 deletions

File tree

flow-server/src/main/java/com/vaadin/flow/component/UI.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import com.vaadin.flow.router.RouteParameters;
7272
import com.vaadin.flow.router.Router;
7373
import com.vaadin.flow.router.RouterLayout;
74+
import com.vaadin.flow.router.RouterState;
7475
import com.vaadin.flow.router.internal.HasUrlParameterFormat;
7576
import com.vaadin.flow.router.internal.PathUtil;
7677
import com.vaadin.flow.server.Command;
@@ -842,6 +843,37 @@ public Signal<Locale> localeSignal() {
842843
return localeSignal.asReadonly();
843844
}
844845

846+
/**
847+
* Gets a read-only signal that holds the current {@link RouterState} of
848+
* this UI.
849+
* <p>
850+
* The signal value is updated whenever a navigation completes, immediately
851+
* before {@link AfterNavigationListener}s are notified, so reactive
852+
* consumers and listeners observe the same state. Use {@link Signal#get()}
853+
* to read reactively (creates a dependency when called inside a
854+
* {@link Signal#effect}). Use {@link Signal#peek()} for a non-reactive
855+
* snapshot.
856+
* <p>
857+
* Before the first navigation completes, the value is a {@code
858+
* RouterState} with an empty {@link Location}, empty
859+
* {@link RouteParameters}, an empty active chain and a {@code null}
860+
* navigation target.
861+
* <p>
862+
* Fine-grained projections can be derived with {@link Signal#map}, for
863+
* example:
864+
*
865+
* <pre>
866+
* Signal&lt;Location&gt; locationSignal = ui.routerStateSignal()
867+
* .map(RouterState::location);
868+
* </pre>
869+
*
870+
* @return a read-only signal holding the current router state, never
871+
* {@code null}
872+
*/
873+
public Signal<RouterState> routerStateSignal() {
874+
return internals.getRouterStateSignal();
875+
}
876+
845877
/**
846878
* Sets the locale for this UI.
847879
* <p>

flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@
7777
import com.vaadin.flow.router.Location;
7878
import com.vaadin.flow.router.NavigationTrigger;
7979
import com.vaadin.flow.router.Route;
80+
import com.vaadin.flow.router.RouteParameters;
8081
import com.vaadin.flow.router.Router;
8182
import com.vaadin.flow.router.RouterLayout;
83+
import com.vaadin.flow.router.RouterState;
8284
import com.vaadin.flow.router.internal.AfterNavigationHandler;
8385
import com.vaadin.flow.router.internal.BeforeEnterHandler;
8486
import com.vaadin.flow.router.internal.BeforeLeaveHandler;
@@ -88,6 +90,7 @@
8890
import com.vaadin.flow.server.communication.PushConnection;
8991
import com.vaadin.flow.shared.Registration;
9092
import com.vaadin.flow.shared.communication.PushMode;
93+
import com.vaadin.flow.signals.Signal;
9194
import com.vaadin.flow.signals.local.ValueSignal;
9295

9396
/**
@@ -200,6 +203,12 @@ public List<Object> getParameters() {
200203
private Location viewLocation = new Location("");
201204
private ArrayList<HasElement> routerTargetChain = new ArrayList<>();
202205

206+
private final ValueSignal<RouterState> routerStateSignal = new ValueSignal<>(
207+
new RouterState(new Location(""), RouteParameters.empty(),
208+
Collections.emptyList(), null));
209+
private final Signal<RouterState> readonlyRouterStateSignal = routerStateSignal
210+
.asReadonly();
211+
203212
private HashMap<Class<?>, List<?>> listeners = new HashMap<>();
204213

205214
private Location lastHandledNavigation = null;
@@ -976,6 +985,30 @@ public Location getActiveViewLocation() {
976985
return viewLocation;
977986
}
978987

988+
/**
989+
* Gets the cached read-only {@link Signal} that holds the current
990+
* {@link RouterState} for this UI. Backs
991+
* {@link com.vaadin.flow.component.UI#routerStateSignal()}.
992+
*
993+
* @return the read-only router state signal, not <code>null</code>
994+
*/
995+
public Signal<RouterState> getRouterStateSignal() {
996+
return readonlyRouterStateSignal;
997+
}
998+
999+
/**
1000+
* Updates the {@link RouterState} value held by this UI's router state
1001+
* signal. Called by the navigation pipeline whenever a navigation
1002+
* completes, immediately before {@link AfterNavigationListener}s are
1003+
* notified.
1004+
*
1005+
* @param state
1006+
* the new router state, not <code>null</code>
1007+
*/
1008+
public void updateRouterState(RouterState state) {
1009+
routerStateSignal.set(state);
1010+
}
1011+
9791012
/**
9801013
* Gets the VaadinSession to which the related UI is attached.
9811014
*
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.router;
17+
18+
import java.io.Serializable;
19+
import java.util.List;
20+
import java.util.Objects;
21+
import java.util.Optional;
22+
23+
import com.vaadin.flow.component.Component;
24+
import com.vaadin.flow.component.HasElement;
25+
26+
/**
27+
* An immutable snapshot of a UI's current navigation state.
28+
* <p>
29+
* Carried by {@link com.vaadin.flow.component.UI#routerStateSignal()} so that
30+
* components can observe the active route reactively, instead of registering an
31+
* {@link AfterNavigationListener} and manually fetching the initial state on
32+
* attach.
33+
* <p>
34+
* The snapshot covers the same data already available through
35+
* {@link AfterNavigationEvent} plus the navigation target class. It is produced
36+
* at the same point that {@code AfterNavigationEvent} is dispatched, so the
37+
* signal value and the listener notification are always consistent.
38+
*
39+
* @param location
40+
* the location of the current view (path, query parameters,
41+
* fragment); never {@code null}. Before the first navigation, this
42+
* is an empty location ({@code new Location("")}).
43+
* @param routeParameters
44+
* route parameters extracted from the URL; never {@code null}. May
45+
* be {@link RouteParameters#empty()}.
46+
* @param activeChain
47+
* unmodifiable list of the currently active route target and its
48+
* parent layouts, leaf first. Never {@code null}; empty before the
49+
* first navigation completes.
50+
* @param navigationTarget
51+
* the class of the leaf route target. {@code null} before the first
52+
* navigation completes.
53+
*/
54+
public record RouterState(Location location, RouteParameters routeParameters,
55+
List<HasElement> activeChain,
56+
Class<? extends Component> navigationTarget) implements Serializable {
57+
58+
public RouterState {
59+
Objects.requireNonNull(location, "location cannot be null");
60+
Objects.requireNonNull(routeParameters,
61+
"routeParameters cannot be null");
62+
Objects.requireNonNull(activeChain, "activeChain cannot be null");
63+
activeChain = List.copyOf(activeChain);
64+
}
65+
66+
/**
67+
* Returns the currently shown leaf view, if any.
68+
*
69+
* @return the leaf route target, or empty if no navigation has happened yet
70+
*/
71+
public Optional<HasElement> currentView() {
72+
return activeChain.isEmpty() ? Optional.empty()
73+
: Optional.of(activeChain.get(0));
74+
}
75+
}

flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractNavigationStateRenderer.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
import com.vaadin.flow.router.RouteParameters;
6868
import com.vaadin.flow.router.Router;
6969
import com.vaadin.flow.router.RouterLayout;
70+
import com.vaadin.flow.router.RouterState;
7071
import com.vaadin.flow.server.Constants;
7172
import com.vaadin.flow.server.HttpStatusCode;
7273
import com.vaadin.flow.server.RouteRegistry;
@@ -396,6 +397,12 @@ private Optional<Integer> handleBeforeNavigationEvents(
396397
*/
397398
private void handleAfterNavigationEvents(UI ui,
398399
RouteParameters parameters) {
400+
ui.getInternals()
401+
.updateRouterState(new RouterState(
402+
locationChangeEvent.getLocation(), parameters,
403+
ui.getInternals().getActiveRouterTargetsChain(),
404+
navigationState.getNavigationTarget()));
405+
399406
List<AfterNavigationHandler> afterNavigationHandlers = new ArrayList<>(
400407
ui.getNavigationListeners(AfterNavigationHandler.class));
401408
afterNavigationHandlers
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component;
17+
18+
import java.util.Collections;
19+
import java.util.List;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import com.vaadin.flow.dom.SignalsUnitTest;
24+
import com.vaadin.flow.router.Location;
25+
import com.vaadin.flow.router.RouteParameters;
26+
import com.vaadin.flow.router.RouterState;
27+
import com.vaadin.flow.signals.Signal;
28+
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertNotNull;
31+
import static org.junit.jupiter.api.Assertions.assertSame;
32+
import static org.junit.jupiter.api.Assertions.assertTrue;
33+
34+
/**
35+
* Unit tests for {@link UI#routerStateSignal()}.
36+
*/
37+
class UIRouterStateSignalTest extends SignalsUnitTest {
38+
39+
@Tag("test-view")
40+
private static class TestView extends Component {
41+
}
42+
43+
@Test
44+
void routerStateSignal_returnsCachedReadonlyInstance() {
45+
UI ui = UI.getCurrent();
46+
Signal<RouterState> a = ui.routerStateSignal();
47+
Signal<RouterState> b = ui.routerStateSignal();
48+
49+
assertNotNull(a, "routerStateSignal() should never return null");
50+
assertSame(a, b,
51+
"Repeated calls must return the same cached read-only signal");
52+
}
53+
54+
@Test
55+
void routerStateSignal_initialValueHasEmptyChain() {
56+
UI ui = UI.getCurrent();
57+
RouterState initial = ui.routerStateSignal().peek();
58+
59+
assertNotNull(initial);
60+
assertEquals("", initial.location().getPath());
61+
assertTrue(initial.activeChain().isEmpty());
62+
assertTrue(initial.currentView().isEmpty());
63+
assertTrue(initial.routeParameters().getParameterNames().isEmpty());
64+
}
65+
66+
@Test
67+
void routerStateSignal_updatedAfterUpdateRouterState() {
68+
UI ui = UI.getCurrent();
69+
Signal<RouterState> signal = ui.routerStateSignal();
70+
71+
TestView leaf = new TestView();
72+
RouterState newState = new RouterState(new Location("foo/bar"),
73+
RouteParameters.empty(), List.of(leaf), TestView.class);
74+
75+
ui.getInternals().updateRouterState(newState);
76+
77+
RouterState observed = signal.peek();
78+
assertEquals("foo/bar", observed.location().getPath());
79+
assertSame(leaf, observed.currentView().orElseThrow());
80+
assertEquals(TestView.class, observed.navigationTarget());
81+
}
82+
83+
@Test
84+
void routerStateSignal_consecutiveUpdatesArePropagated() {
85+
UI ui = UI.getCurrent();
86+
Signal<RouterState> signal = ui.routerStateSignal();
87+
88+
ui.getInternals().updateRouterState(new RouterState(new Location("a"),
89+
RouteParameters.empty(), Collections.emptyList(), null));
90+
assertEquals("a", signal.peek().location().getPath());
91+
92+
ui.getInternals().updateRouterState(new RouterState(new Location("b"),
93+
RouteParameters.empty(), Collections.emptyList(), null));
94+
assertEquals("b", signal.peek().location().getPath());
95+
}
96+
}

0 commit comments

Comments
 (0)