Skip to content

feat(routing): Preserve query-aware navigation and partial template delivery#109

Merged
mohamedmansour merged 3 commits intomainfrom
fix/entry-partials
Mar 14, 2026
Merged

feat(routing): Preserve query-aware navigation and partial template delivery#109
mohamedmansour merged 3 commits intomainfrom
fix/entry-partials

Conversation

@mohamedmansour
Copy link
Copy Markdown
Contributor

Scope

This staged set is doing two tightly related things:

  1. Fixing URL handling so client navigation preserves query strings for server state requests while still matching routes by pathname.
  2. Replacing ad hoc partial-template selection with a reusable, route-aware, state-agnostic implementation that works across Rust, FFI, and .NET hosts.

Why we changed this

1. Query-driven routes need two path concepts

Apps like the commerce example use query parameters as real state, especially for search and sorting (q, sort, etc.).

That means the client router and the server cannot treat "the route path" and "the full request URL" as the same thing:

  • route matching should use the pathname only
  • state fetching and browser history should preserve the full request path, including the query string

Without that split, the router can match the correct route but still ask the server for the wrong state, which causes SSR and hydrated navigation to drift apart.

2. Partial-template delivery needed to become reusable

Before this change, the partial-template logic was mostly centered in crates\webui-cli\src\commands\serve.rs.

That worked for the built-in dev server, but it had two problems:

  • the logic was not reusable from the framework layer
  • non-CLI hosts such as FFI and .NET could not ask for "templates for the active route chain" in the same way

Because this is library/framework behavior, the real implementation belongs in webui-handler, with the CLI acting as a thin caller.

3. We wanted route awareness without state-aware pruning

This change intentionally does not evaluate runtime if conditions, loop collections, or component-prop state while deciding which templates to ship.

That is deliberate.

Once the client hydrates, the client becomes authoritative for state changes. Template shipping should therefore behave like a capability bundle for:

  • the persistent shell
  • the active route chain

It should not try to mirror a transient server-state snapshot and risk under-shipping templates the client may need immediately after hydration.

At the same time, we still do not want to walk every sibling <route> branch. So the chosen design is:

  • state-agnostic
  • route-aware
  • nested-route aware

High-level outcome

After this staged change:

  1. The router carries both pathname and requestPath.
  2. Client-side route matching uses pathname.
  3. Server JSON partial requests use requestPath, so query strings are preserved.
  4. Partial-template selection starts at the persistent entry fragment and walks only the active nested route chain.
  5. Conditional, loop, and attribute-template edges are still traversed conservatively.
  6. The same behavior is available from:
    • webui-handler
    • webui
    • webui-ffi
    • the .NET wrapper

End-to-end flow after the change

Client side

  1. The router builds a NavigationTarget containing:
    • pathname: for local route matching
    • requestPath: for fetch/history, including query string
  2. The router matches the best route using pathname.
  3. If it needs server data/templates, it fetches the JSON partial using requestPath.
  4. The router sends the current template inventory in X-WebUI-Inventory.

Server side

  1. webui-cli serve splits the incoming path into:
    • route_path: pathname only
    • request_path: pathname plus query string
  2. Route matching uses route_path.
  3. State resolution uses request_path.
  4. Route params are injected based on the matched route.
  5. The CLI calls the reusable request-aware helper in webui-handler.

Template discovery

The request-aware helper:

  1. starts at the persistent entry fragment
  2. walks the normal fragment graph iteratively
  3. follows component, if, for, and attribute-template edges conservatively
  4. when it sees sibling <route> fragments, it picks only the best match for the current request path
  5. recurses through nested matched route groups by repeating that same rule
  6. filters discovered component IDs against the inventory bitset
  7. returns only missing templates plus the updated inventory

What changed, file by file

Router package

packages\webui-router\src\navigation-path.ts

This file introduces the explicit split between:

  • pathname
  • requestPath

Key behavior:

  • buildNavigationTarget() strips the base path from url.pathname
  • requestPath is built as pathname + url.search
  • prependBasePath() works on the full request path, so queries are not dropped on navigation or fallback

This is the core client-side fix that keeps route matching and request-state resolution separate.

packages\webui-router\src\router.ts

This file applies the new path model everywhere it matters:

  • handleNavigation() matches using pathname
  • fallback navigation uses requestPath
  • fetchAndMount() fetches requestPath
  • the webui:route:navigated event now reports the full request path
  • inventory is still sent via X-WebUI-Inventory

This is the behavioral bridge between the router fix and the server-side partial-template work.

packages\webui-router\src\types.ts

This updates the public type surface to match the new behavior, especially the fact that navigation event paths may include query strings.

packages\webui-router\test\navigation-path.test.ts

Adds focused tests for:

  • preserving query strings in request paths
  • normalizing base-path roots to /
  • prepending base paths without dropping query strings

packages\webui-router\test\navigation-path.test.js

This is the bundled test artifact used by the package test script.

packages\webui-router\package.json

Adds the explicit navigation-path test flow so the router package validates the new URL helper behavior.

Framework route-template selection

crates\webui-handler\src\route_handler.rs

This is the center of the staged change.

The file now provides two request-aware helpers:

  • get_needed_components_for_request(...)
  • get_route_templates_for_request(...)

The implementation keeps the existing inventory model, but adds request-aware traversal on top of the existing fragment graph.

Important design points:

  • traversal is iterative, not recursive
  • route matching reuses the existing handler matcher semantics
  • sibling route groups are pruned to the single best match
  • nested route groups are supported by applying the same rule at each level
  • if, for, and attribute-template edges are still followed conservatively
  • inventory filtering remains deterministic via the existing FNV-based bit position logic

The helper deliberately avoids "state-aware discovery". It over-ships within the active route chain rather than risking under-shipping after hydration.

This file also adds targeted tests covering:

  • shell + matched route chain behavior
  • nested route-chain behavior
  • exclusion of unvisited sibling routes
  • conservative over-shipping inside matched routes
  • inventory stability across repeated navigations

CLI integration

crates\webui-cli\src\commands\serve.rs

This file now acts as a thin integration layer instead of owning the core partial-template algorithm.

Relevant changes:

  • RequestPaths now clearly separates route_path and request_path
  • build_request_paths() preserves query strings
  • handle_json_partial():
    • resolves state from request_path
    • matches routes using route_path
    • injects route params from the match result
    • delegates template selection to webui_handler::route_handler::get_needed_components_for_request(...)

The key architectural improvement here is that serve.rs no longer invents its own route + entry union strategy. It now relies on the framework helper rooted at the entry fragment.

Rust public API

crates\webui\src\lib.rs

The new request-aware helpers are re-exported from the top-level Rust crate:

  • get_needed_components_for_request
  • get_route_templates_for_request

This matters because it makes the behavior available to custom Rust hosts without forcing them to depend on internal crate layout.

FFI surface

crates\webui-ffi\src\lib.rs

The FFI route-template API is updated from an entry-only shape to a request-aware shape.

webui_get_route_templates(...) now accepts:

  • protocol_data
  • protocol_len
  • entry_id
  • request_path
  • inventory_hex

Behavioral changes:

  • calls the new get_route_templates_for_request(...) helper
  • validates all incoming pointers and UTF-8 inputs
  • wraps the full body in std::panic::catch_unwind() so panics cannot cross the FFI boundary
  • returns JSON with templates and updated inventory

While touching the FFI surface, set_last_error(...) was also tightened so it no longer depends on unwrap/expect when preparing the thread-local error string.

crates\webui-ffi\include\webui_ffi.h

The header is kept in sync with the Rust ABI by adding the new request_path parameter and updating the function documentation to describe active-route-chain selection.

crates\webui-ffi\tests\ffi_test.rs

Adds an integration test that verifies the FFI entrypoint returns templates only for the active route chain, not unrelated sibling routes.

.NET host surface

dotnet\src\Microsoft.WebUI\NativeBindings.cs

Updates the P/Invoke declaration of webui_get_route_templates(...) to include requestPath.

dotnet\src\Microsoft.WebUI\WebUIHandler.cs

Updates the public wrapper method:

  • old shape: GetRouteTemplates(protocol, entryId, inventoryHex)
  • new shape: GetRouteTemplates(protocol, entryId, requestPath, inventoryHex)

This keeps the .NET API aligned with the Rust and C ABI changes.

Specification

DESIGN.md

The specification now describes the intended partial-template behavior explicitly:

  • traversal starts from the persistent entry fragment
  • selection is route-aware but state-agnostic
  • only the active nested route chain is followed
  • inactive sibling route branches are skipped
  • inactive if / for branches within the active route chain may still be over-shipped intentionally

This matters because the behavior is not just an implementation detail anymore; it is part of the framework contract.

Important implementation decisions

We reused the existing route matcher instead of inventing a second one

The request-aware helper uses the same route matching semantics already used by SSR. That keeps server rendering and partial-template selection aligned.

We did not add new protocol fields

This change does not introduce a new discovery graph or protocol format. It reuses the existing fragment graph plus route matching.

That keeps the solution simpler, easier to reason about, and cheaper to propagate across hosts.

We kept inventory filtering deterministic

The inventory bitset continues to use the FNV-1a-based component hashing model so client and server stay aligned on what is already loaded.

We scoped route pruning, not state pruning

The crucial distinction in this change is:

  • do prune unmatched sibling route branches
  • do not prune conditional or loop-driven branches based on transient request-time state

That is the core "why" of the chosen design.

Validation performed

The staged change was validated with:

cargo test -p webui-handler
cargo test -p webui-cli
cargo test -p webui-ffi
dotnet build D:\webui\dotnet\src\Microsoft.WebUI\Microsoft.WebUI.csproj -nologo
cargo xtask check

What the next reader should keep in mind

If you need to change this area later, the main invariants are:

  1. route matching uses pathname semantics
  2. state fetching preserves the full request path, including query string
  3. partial-template selection starts at the entry fragment, not the matched route fragment alone
  4. route pruning is allowed, state pruning is not
  5. Rust, FFI, header, and .NET surfaces must stay in sync

If any future change breaks one of those invariants, expect SSR, hydration, or client navigation to drift apart again.

Comment thread packages/webui-router/src/router.ts Fixed
@mohamedmansour mohamedmansour requested a review from a team March 14, 2026 17:11
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/WebUIHandler.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/WebUIHandler.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/WebUIHandler.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/WebUIHandler.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/WebUIHandler.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Fixed
Copy link
Copy Markdown
Contributor Author

@mohamedmansour mohamedmansour left a comment

Choose a reason for hiding this comment

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

Fixed code review

Comment on lines +55 to +61
IntPtr resultPtr = NativeBindings.webui_handler_render(
_handle,
protocol,
(nuint)protocol.Length,
stateJson,
entryId,
requestPath);
Comment on lines +90 to +95
IntPtr resultPtr = NativeBindings.webui_get_route_templates(
protocol,
(nuint)protocol.Length,
entryId,
requestPath,
inventoryHex);

protected override bool ReleaseHandle()
{
webui_handler_destroy_raw(handle);
internal static WebUIHandlerSafeHandle CreateHandler(string? pluginId)
{
IntPtr handle = pluginId is null
? webui_handler_create_raw()
{
IntPtr handle = pluginId is null
? webui_handler_create_raw()
: webui_handler_create_with_plugin_raw(pluginId);
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Dismissed
Comment thread dotnet/src/Microsoft.WebUI/NativeBindings.cs Dismissed
}

[DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "webui_handler_create")]
private static extern IntPtr webui_handler_create_raw();
private static extern IntPtr webui_handler_create_raw();

[DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "webui_handler_create_with_plugin")]
private static extern IntPtr webui_handler_create_with_plugin_raw(
[MarshalAs(UnmanagedType.LPUTF8Str)] string? pluginId);

[DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "webui_handler_destroy")]
private static extern void webui_handler_destroy_raw(IntPtr handlerPtr);
Copy link
Copy Markdown
Contributor

@janechu janechu left a comment

Choose a reason for hiding this comment

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

I think we might try adding information for the agents about unmanaged code being part of the design so they could perhaps stop leaving those comments. Also I would like to see some mermaid diagrams in future, GitHub will render these.

@mohamedmansour mohamedmansour merged commit 4a65642 into main Mar 14, 2026
15 checks passed
@mohamedmansour mohamedmansour deleted the fix/entry-partials branch March 14, 2026 21:16
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.

2 participants