Skip to content

feat: add route hierarchy with dynamic titles#24550

Merged
tltv merged 18 commits into
mainfrom
feat/instance-free-dynamic-page-title
Jun 11, 2026
Merged

feat: add route hierarchy with dynamic titles#24550
tltv merged 18 commits into
mainfrom
feat/instance-free-dynamic-page-title

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Introduces a new, instance-free mechanism for resolving page titles and logical route hierarchies in Vaadin applications. The main focus is on enabling dynamic page titles and hierarchical navigation aids (like breadcrumbs and menus) without requiring instantiation of navigation target components. This is achieved through new annotations, interfaces, and supporting APIs for stateless resolution of titles and parent routes.

  • Introduced the @DynamicPageTitle annotation, allowing routes to specify a PageTitleGenerator for computing their titles without needing to instantiate the route component. This enables dynamic titles for navigation elements like menus and breadcrumbs.
  • Added the PageTitleGenerator interface and PageTitleContext record, providing a stateless, parameter-driven way to generate page titles for routes.
  • Added the @RouteParent annotation and supporting APIs, allowing routes to declare their logical parent statically or via a resolver, again without requiring instantiation.
  • Extended RouteConfiguration with methods to resolve a route's logical parent and hierarchy statelessly, supporting the new navigation aids.
  • Updated Instantiator and DefaultInstantiator to support application-wide default PageTitleGenerator resolution, integrating the new mechanism with the dependency injection system.
  • Enhanced MenuRegistry and related utilities to use the new title and hierarchy resolution APIs, allowing navigation aids to display correct, dynamic titles and hierarchies for all routes.

HasDynamicTitle is an instance method, so the only way to get a dynamic
title for a route is to create the view. That is a problem for breadcrumbs,
menus and similar navigation aids that need the titles of a whole trail of
routes without instantiating any of them. Until now the only instance-free
title source was the static @PageTitle value.

Introduce PageTitleGenerator, a stateless counterpart of HasDynamicTitle
that resolves a title purely from the navigation target class and its
RouteParameters (passed via a PageTitleContext). A route opts in through the
new @PageTitle(generator = ...) attribute. The generator is created through
the application Instantiator (so DI works), never the route itself.

@PageTitle#value() now defaults to an empty string so a route can declare a
generator without a redundant value, and the generator takes precedence over
the static value when both are present.

Title resolution honours the generator in both code paths: MenuRegistry.getTitle
(used by menus, page headers and other instance-free consumers, with a new
overload that accepts RouteParameters) and AbstractNavigationStateRenderer
when applying the title during navigation. Existing behaviour is unchanged
for routes that use a plain @PageTitle or no annotation.
@Artur- Artur- marked this pull request as draft June 9, 2026 08:49
@github-actions github-actions Bot added the +0.1.0 label Jun 9, 2026
Add @RouteParent as a purely logical navigation hierarchy, independent of
the @Route/@RoutePrefix layout chain, so breadcrumbs and hierarchical menus
can walk the ancestor trail of a route without instantiating any of them.

Mirroring the PageTitleGenerator design, the parent can be declared either
statically via @RouteParent(value) or dynamically via
@RouteParent(resolver = ...), where a stateless RouteParentResolver computes
the parent from the route class and its RouteParameters (handed in through a
RouteParentContext). The resolver returns a RouteParentReference carrying the
parent class together with the RouteParameters it should be resolved with,
since a parent route usually inherits only a subset of the child parameters.
Resolvers are created through the application Instantiator (DI), never the
route itself; the resolver takes precedence over a static value.

RouteUtil.getRouteParent resolves a single level and
RouteUtil.getBreadcrumbTrail climbs to the hierarchy root (with a cycle
guard), returning the trail ordered root-to-current. Each entry carries the
parameters needed to resolve its own title via MenuRegistry.getTitle, so the
two features compose: the breadcrumb labels of routes that are never
instantiated are produced by their PageTitleGenerator.
@totally-not-ai totally-not-ai Bot changed the title feat: resolve dynamic page titles without a view instance feat: resolve dynamic page titles and logical route parents without a view instance Jun 9, 2026
Drop the hand-written getters, no-arg-style convenience constructor and
null-check compact constructors from PageTitleContext, RouteParentContext and
RouteParentReference. They are plain data carriers, so the record components
(navigationTarget/routeParameters accessors) are all that is needed. Update
call sites and Javadoc examples to use the canonical record accessors.
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Test Results

 1 442 files  + 2   1 442 suites  +2   1h 23m 20s ⏱️ - 1m 47s
10 156 tests +30  10 088 ✅ +30  68 💤 ±0  0 ❌ ±0 
10 628 runs  +30  10 559 ✅ +30  69 💤 ±0  0 ❌ ±0 

Results for commit f9c0c0c. ± Comparison against base commit b8b4e08.

♻️ This comment has been updated with latest results.

totally-not-ai Bot added 2 commits June 9, 2026 09:33
The previous name described one use case (breadcrumbs) rather than what the
method returns. It actually resolves the logical route hierarchy: the chain
of the navigation target and all its @RouteParent ancestors, ordered from the
root to the target. Breadcrumbs and hierarchical menus are consumers of that
chain, not what the method itself produces.
Restore the URL-based parent resolution as the fallback in the route parent
chain. RouteUtil.getRouteParent now resolves the logical parent in this order:

1. @RouteParent annotation (its dynamic resolver, else its static value);
2. otherwise the parent is derived from the route URL by walking up to the
   registered route serving the nearest ancestor path.

The URL fallback resolves the target's url with the given parameters, then
trims trailing path segments until a different registered navigation target
is found, carrying over the route parameters that apply to that ancestor
path. It is skipped when no VaadinService is available. This keeps the
hierarchy working for the common case where routes are nested by URL and only
need @RouteParent for non-URL-aligned hierarchies.
Comment thread flow-server/src/main/java/com/vaadin/flow/router/PageTitleContext.java Outdated
Comment thread flow-server/src/main/java/com/vaadin/flow/router/PageTitle.java Outdated
totally-not-ai Bot added 3 commits June 9, 2026 10:04
Address review feedback on PageTitleContext:

- Add the query parameters to PageTitleContext so a generator can resolve a
  title that depends on the query string, not only the route parameters. They
  are taken from the navigation Location, and default to
  QueryParameters.empty() for the instance-free menu/breadcrumb paths.
- Add the declared PageTitle value to PageTitleContext and stop ignoring it
  when a generator is set. The value is now handed to the generator, so a
  single application-wide generator can resolve many routes from their
  declared value (for example treating it as an i18n message key) instead of
  needing a dedicated generator class per route.
Following the I18NProvider / MenuAccessControl provider pattern, add
Instantiator.getPageTitleGenerator() so an application can define a single
default PageTitleGenerator instead of referencing a generator class from
every @PageTitle.

- DefaultInstantiator resolves it from the new "pageTitle.generator" init
  parameter (a class name), returning null when none is configured.
- SpringInstantiator resolves it from a PageTitleGenerator bean, falling back
  to the init-parameter default when there is no unique bean, so a Spring
  aware generator works out of the box.

Title resolution order in MenuRegistry.getTitle and
AbstractNavigationStateRenderer becomes: per-route @PageTitle generator, then
the application-wide default generator, then the static @PageTitle value (or
the class simple name for menus). The declared value is handed to the default
generator via PageTitleContext, so a generic generator can resolve every
route from its value (for example as an i18n key).
A static @RouteParent forwarded the child's full RouteParameters to the
parent unchanged. When the parent route declares fewer (or no) parameters,
building a RouterLink to it failed with NotFoundException, e.g. an
OrderDetailView (order-detail/:orderId) with @RouteParent(OrdersView.class)
handed OrdersView (no parameters) an {orderId=...} it cannot accept.

getRouteParent now narrows a static parent's parameters to the names the
parent's own route template declares, matching what the URL-derived
resolution already does. When no registry is available the parameters are
left unchanged.
Address review feedback: getRouteParent / getRouteHierarchy no longer hardcode
RouteConfiguration.forApplicationScope() nor depend on the
ApplicationRouteRegistry class. They now have an overload taking an explicit
RouteRegistry, with the existing no-registry overloads resolving the default
via VaadinService.getCurrent().getRouter().getRegistry(). The registry is used
through RouteConfiguration.forRegistry(...) and RouteRegistry#getNavigationRouteTarget.

Expand RouteUtilTest hierarchy coverage to match the scenarios in the sibling
RouteHierarchyTest: class without @route, single route, URL-prefix happy path,
deep multi-step chain, missing intermediate terminating at the leaf,
empty-template root, @RouteParent precedence over the URL prefix, cycle
truncation without duplicates, and a resolver returning empty marking a root.
@Artur- Artur- marked this pull request as ready for review June 10, 2026 10:09
totally-not-ai Bot added 3 commits June 10, 2026 10:35
…otation

Per review discussion, keep @PageTitle as the static-title annotation it has
always been and express instance-free dynamic titles through a dedicated
annotation instead of overloading @PageTitle with a generator attribute.

- Add @DynamicPageTitle(Class<? extends PageTitleGenerator>) and remove the
  @PageTitle#generator() attribute; @PageTitle#value() is required again.
- Resolution order is unchanged in behavior: per-route @DynamicPageTitle
  generator, then the application-wide default generator, then the static
  @PageTitle value (or class simple name for menus). A @PageTitle value
  declared alongside @DynamicPageTitle is still handed to the generator via
  PageTitleContext#value() as a key.

This follows the Flow convention of a feature-named annotation plus a
role-named strategy type (e.g. @theme + AbstractTheme), avoiding the
@DomEvent/DomEvent same-name-different-package pattern and the sentinel
default on @PageTitle.
Reconcile the review discussion on the getRouteParent / getRouteHierarchy
overloads: keep them taking a RouteRegistry (consistent with the rest of
RouteUtil, which all use RouteRegistry) and make obtaining a scoped registry
ergonomic by adding RouteConfiguration#getRegistry().

Developers can now write RouteConfiguration.forApplicationScope().getRegistry()
or RouteConfiguration.forSessionScope().getRegistry() instead of resolving the
session/application registry directly.
Per review, make RouteConfiguration the single public entry point for
resolving a route's logical parent and hierarchy, since it is the stateless
public API over a route registry and lets callers pick the scope
(forApplicationScope() / forSessionScope()).

- Add RouteConfiguration#getRouteParent and #getRouteHierarchy, delegating to
  the internal RouteUtil implementation with this configuration's registry.
- Drop the RouteConfiguration#getRegistry() accessor and the no-argument
  RouteUtil.getRouteParent/getRouteHierarchy overloads (and getDefaultRegistry)
  so there is a single way to do this rather than several. RouteUtil keeps the
  RouteRegistry-based implementation used by the delegation and internal tests.
- Move the public-API tests to RouteConfigurationTest; RouteUtilTest keeps the
  detailed scenario coverage against the internal registry-based methods.
Comment thread flow-server/src/test/java/com/vaadin/flow/router/internal/RouteUtilTest.java Outdated
MenuRegistry#getTitle and AbstractNavigationStateRenderer#resolveTitleFromTarget
implemented the same @DynamicPageTitle / default-generator / @PageTitle value
resolution chain. Extract it into a single RouteUtil#resolvePageTitle that
returns an Optional, so callers apply their own fallback (empty title for
navigation, class simple name for menus). Removes the duplicated generator
instantiation and default-generator lookup from MenuRegistry.

Also fix a stale comment in RouteUtilTest (RootResolvedView, not
OrderDetailView).
Note in the HasDynamicTitle javadoc that it resolves the title from the live
navigation target instance, and point to the instance-free counterpart
DynamicPageTitle (with PageTitleGenerator) for resolving titles of routes that
are not shown, such as breadcrumb or menu entries.

@tltv tltv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last findings are about tests. Add ITs covering new annotations and new public API. Add them in flow-test-root-context-npm module.

Add DefaultInstantiatorPageTitleGeneratorTest mirroring
DefaultInstantiatorMenuAccessControlTest: no property returns null, a valid
class name is instantiated, and an invalid type throws with a descriptive
message.
Add integration tests in the test-root-context (flow-test-root-context-npm)
module covering the new API:

- DynamicPageTitleView + a ViewTitleIT case asserting that the browser title
  is produced by a @DynamicPageTitle PageTitleGenerator during navigation.
- RouteHierarchyParentView / RouteHierarchyView + RouteHierarchyIT, which
  render a breadcrumb built from RouteConfiguration#getRouteHierarchy
  (@RouteParent ancestor + current view) with each title resolved without
  instantiating the ancestor route, and assert both the breadcrumb text and
  the navigated page title.
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

Added ITs in the flow-test-root-context-npm module (75efade), covering the new annotations and public API end-to-end in a browser:

  • DynamicPageTitleView + a new ViewTitleIT case — asserts the browser title is produced by a @DynamicPageTitle PageTitleGenerator during navigation.
  • RouteHierarchyParentView / RouteHierarchyView + RouteHierarchyIT — renders a breadcrumb built from RouteConfiguration#getRouteHierarchy (the @RouteParent ancestor + current view), with each title resolved without instantiating the ancestor route, and asserts both the breadcrumb text and the navigated page title.

The module test-compiles against the new APIs; the browser runs happen in CI.

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@tltv tltv changed the title feat: resolve dynamic page titles and logical route parents without a view instance feat: add route hierarchy with dynamic titles Jun 11, 2026
@tltv tltv added this pull request to the merge queue Jun 11, 2026
Merged via the queue into main with commit 4b81a63 Jun 11, 2026
34 checks passed
@tltv tltv deleted the feat/instance-free-dynamic-page-title branch June 11, 2026 08:16
Artur- added a commit that referenced this pull request Jun 11, 2026
…4582)

This PR cherry-picks changes from the original PR #24550 to branch 25.2.
---
#### Original PR description
> Introduces a new, instance-free mechanism for resolving page titles
and logical route hierarchies in Vaadin applications. The main focus is
on enabling dynamic page titles and hierarchical navigation aids (like
breadcrumbs and menus) without requiring instantiation of navigation
target components. This is achieved through new annotations, interfaces,
and supporting APIs for stateless resolution of titles and parent
routes.
> 
> * Introduced the `@DynamicPageTitle` annotation, allowing routes to
specify a `PageTitleGenerator` for computing their titles without
needing to instantiate the route component. This enables dynamic titles
for navigation elements like menus and breadcrumbs.
> * Added the `PageTitleGenerator` interface and `PageTitleContext`
record, providing a stateless, parameter-driven way to generate page
titles for routes.
> * Added the `@RouteParent` annotation and supporting APIs, allowing
routes to declare their logical parent statically or via a resolver,
again without requiring instantiation.
> * Extended `RouteConfiguration` with methods to resolve a route's
logical parent and hierarchy statelessly, supporting the new navigation
aids.
> * Updated `Instantiator` and `DefaultInstantiator` to support
application-wide default `PageTitleGenerator` resolution, integrating
the new mechanism with the dependency injection system.
> * Enhanced `MenuRegistry` and related utilities to use the new title
and hierarchy resolution APIs, allowing navigation aids to display
correct, dynamic titles and hierarchies for all routes.

Co-authored-by: totally-not-ai[bot] <290682512+totally-not-ai[bot]@users.noreply.github.com>
Co-authored-by: Artur Signell <artur@vaadin.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants