-
-
Notifications
You must be signed in to change notification settings - Fork 0
Navigation Plugin
- Name: Navigation Plugin
-
Package:
@real-router/navigation-plugin - Purpose: Integrates Real Router with the browser Navigation API, synchronizing router state with browser URL and providing route-level history access unavailable in the History API.
- Typical scenarios: SPAs targeting modern browsers where you need to inspect session history entries, check which routes a user has visited, or jump directly to a past history entry without stepping through intermediate pages.
npm install @real-router/navigation-plugin
# or
pnpm add @real-router/navigation-pluginimport { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const router = createRouter(routes);
router.usePlugin(navigationPluginFactory(options));Peer dependency: @real-router/core
| Option | Type | Default | Description |
|---|---|---|---|
base |
string |
"" |
Base path for all routes (e.g., "/app") |
forceDeactivate |
boolean |
false |
If true, browser back/forward skip canDeactivate guards. Default false respects guards — matches browser-plugin and the core router's guard contract. |
Same two options as browser-plugin. The plugins are interchangeable at the options level.
Breaking change in plugin v0.6.0 (#524): the previous default was
true, which silently bypassed everycanDeactivateguard on browser back/forward. Apps that depended on that bypass must now opt in explicitly:navigationPluginFactory({ forceDeactivate: true }).
Looking for hash-based routing? Use
@real-router/hash-plugininstead.
// Minimal configuration — canDeactivate guards run on browser back/forward by default
navigationPluginFactory();
// With base path
navigationPluginFactory({ base: "/app" });
// URL: example.com/app/users
// Opt in to bypassing canDeactivate guards on back/forward (old default)
navigationPluginFactory({ forceDeactivate: true });The plugin performs runtime validation at factory time and throws a plain Error with a descriptive message on any violation.
Type checks
- Validates option types against defaults (string for
base, boolean forforceDeactivate).
base rules (via safeBaseRule from the shared browser-env)
- Must not contain control characters (
\u0000-\u001F,\u007F). - Must not contain
..path segments (e.g.,/app/../evil). - Normalised to canonical form via
normalizeBase: leading/, no trailing/, no//runs."app"→"/app","/app/"→"/app","//app//"→"/app","/"→"".
navigationPluginFactory({ base: "../evil" }); // throws: must not contain '..' segments
navigationPluginFactory({ base: "/app\nX" }); // throws: must not contain control charactersThe Navigation API (~89% global coverage as of 2026) gives you access to the full session history as structured data. Unlike the History API, you can inspect every entry, check what routes the user has visited, and traverse directly to a specific past entry.
// Not possible with browser-plugin:
router.peekBack(); // what's one step back?
router.hasVisited("checkout"); // did the user visit checkout?
router.getVisitedRoutes(); // all routes in this session
router.traverseToLast("users.list"); // jump back to the last users listBrowser support: Chrome 102+, Firefox 147+, Safari 26.2+, Opera 88+. For broader support, use @real-router/browser-plugin as a fallback (see Feature Detection).
navigation-plugin runs in desktop WebViews too, but availability depends on the host:
- Electron — always supported (Chromium ships the Navigation API since Chrome 102, 2022).
- Tauri Windows / Android — supported (Chromium-based WebView).
- Tauri macOS / iOS / Linux — depends on the WebKit version. See the full matrix in the Desktop Integration Guide.
On an unsupported WebView the factory throws immediately:
Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.
This is intentional (fail-fast) — the exclusive methods (peekBack, hasVisited, traverseToLast, etc.) cannot be emulated without the API.
The plugin implements the following hooks:
| Hook | Implemented | Description |
|---|---|---|
onStart |
✅ | Subscribes to Navigation API navigate events |
onStop |
✅ | Unsubscribes from navigate events |
onTransitionStart |
✅ | Writes captured NavigationMeta to state.context.navigation (browser-initiated only — programmatic router.navigate() has no captured meta at this point, so guards see toState.context.navigation as undefined) |
onTransitionSuccess |
✅ | Updates NavigationHistoryEntry state and browser URL |
onTransitionError |
✅ | Clears capturedMeta and pendingTraverseKey
|
onTransitionCancel |
✅ | Clears capturedMeta and pendingTraverseKey
|
teardown |
✅ | Cleans up listeners, removes router extensions |
- Subscribes to the Navigation API
navigateevent - Removes existing listener if plugin is restarted
- Determines:
push(new entry) orreplace(replacement) viashouldReplaceHistory -
replaceis used when:- First navigation (
fromStateis absent) andreplaceis not explicitlyfalse -
replace: trueoption -
reload: trueoption with equal states
- First navigation (
- Preserves hash fragment when navigating to the same path (or on the first navigation with no
fromState) - Note: passing
replace: falseon the first navigation forcespush— this is an explicit user override
- Removes event listeners
- Removes start interceptor
- Removes router extensions (
buildUrl,matchUrl,replaceHistoryState, and all exclusive extensions) viaextendRouterunsubscribe - Releases
"navigation"context namespace claim
The plugin adds the following methods to the router instance via extendRouter():
| Method | Returns | Description |
|---|---|---|
buildUrl(name, params?, options?: { hash? }) |
string |
Build full URL with base path. Optional hash (decoded) is encoded and appended (#532). |
matchUrl(url) |
State | undefined |
Parse URL to router state |
replaceHistoryState(name, params?, options?: { hash? }) |
void |
Update browser URL without triggering navigation. Tri-state hash: undefined preserves, "" clears, value sets. (#532) |
router.buildUrl("users", { id: "123" });
// => "/app/users/123" (with base "/app")
router.matchUrl("/app/users/123");
// => { name: "users", params: { id: "123" }, path: "/users/123" }
// Update URL silently (no transition, no guards)
router.replaceHistoryState("users", { id: "456" });router.buildPath("users", { id: 1 }); // "/users/1" — core, no base
router.buildUrl("users", { id: 1 }); // "/app/users/1" — plugin, with baserouter.replaceHistoryState(name, params); // URL only, no transition
router.navigate(name, params, { replace: true }); // Full transition + URL updateThese methods are only available with navigation-plugin. They have no equivalent in browser-plugin.
| Method | Returns | Description |
|---|---|---|
peekBack() |
State | undefined |
State of the previous history entry |
peekForward() |
State | undefined |
State of the next history entry |
hasVisited(routeName) |
boolean |
Whether any history entry matches the route |
getVisitedRoutes() |
string[] |
Unique route names across all history entries |
getRouteVisitCount(routeName) |
number |
How many history entries match the route |
traverseToLast(routeName) |
Promise<State> |
Navigate to the last history entry for a route |
canGoBack() |
boolean |
Whether there's a previous history entry |
canGoForward() |
boolean |
Whether there's a next history entry |
canGoBackTo(routeName) |
boolean |
Whether any previous entry matches the route |
Preview where back/forward would take the user without navigating:
const prev = router.peekBack();
if (prev) {
console.log(`Back goes to: ${prev.name}`);
}
const next = router.peekForward();
if (next) {
console.log(`Forward goes to: ${next.name}`);
}Inspect the session history to understand where the user has been:
// Check if the user has been to a route in this session
if (router.hasVisited("checkout")) {
showResumeCheckoutBanner();
}
// Get all routes visited in this session
const visited = router.getVisitedRoutes();
// => ["home", "users.list", "users.view", "checkout"]
// How many times did the user visit the product page?
const count = router.getRouteVisitCount("products.view");Jump directly to the last time the user was on a given route. Skips intermediate entries without stepping through them one by one:
await router.traverseToLast("users.list");Drive UI state for navigation controls:
// Disable back/forward buttons when there's nowhere to go
const backDisabled = !router.canGoBack();
const forwardDisabled = !router.canGoForward();
// Show "back to list" only if the user actually came from the list
if (router.canGoBackTo("users.list")) {
showBackToListButton();
}In addition to state.context.navigation, navigation-plugin claims a second namespace state.context.url for URL fragment support. Both namespaces are populated on every successful transition.
interface UrlContext {
/** Decoded URL fragment, no leading `#`. Empty string when URL has no fragment. */
hash: string;
/** True only on browser-driven hash-only navigation (Navigation API `event.hashChange === true`). */
hashChanged: boolean;
}Hash-only browser-driven clicks (event.hashChange === true from navigate-handler.ts) bypass core's SAME_STATES rejection via force: true, hashChange: true — subscribers fire normally on tab-style URLs.
Tri-state semantics for opts.hash in router.navigate(name, params, { hash }):
-
undefined(default) — preserve current hash -
""— clear the hash -
"value"— set the hash
Same widening on router.buildUrl(name, params, { hash }) and router.replaceHistoryState(name, params, { hash }). Tri-state replaces the legacy shouldPreserveHash heuristic — there is no longer any "hash dropped on cross-path navigation" case.
navigation-plugin and @real-router/browser-plugin both claim the "url" namespace — they cannot be installed on the same router. @real-router/hash-plugin is also mutually exclusive (it owns # as the route delimiter; see hash-plugin for the documented limitation).
For the full surface (encoding, F5 priming, hash-aware sources, recovery paths) see Hash.
The navigation plugin writes navigation metadata to state.context.navigation on every transition. This replaces the former getNavigationMeta() router extension -- metadata is now directly on the state object, available in subscribe callbacks and components without calling a separate method.
interface NavigationMeta {
/** Type of navigation: push, replace, traverse, or reload */
navigationType: "push" | "replace" | "traverse" | "reload";
/** Whether the navigation was initiated by the user (back/forward button, link click) */
userInitiated: boolean;
/** Ephemeral info passed via navigation.navigate({ info }) -- lost on page reload */
info?: unknown;
/** Direction of navigation in the history stack */
direction: "forward" | "back" | "unknown";
/** The DOM element that initiated the navigation (e.g., anchor tag), or null for programmatic */
sourceElement: Element | null;
}The plugin augments StateContext from @real-router/types:
declare module "@real-router/types" {
interface StateContext {
navigation?: NavigationMeta;
}
}Import the plugin package to activate type inference:
import "@real-router/navigation-plugin";// In subscribe callback
router.subscribe((state) => {
const nav = state.context.navigation;
if (nav) {
console.log(nav.navigationType); // "push" | "replace" | "traverse" | "reload"
console.log(nav.userInitiated); // true if user clicked back/forward/link
console.log(nav.direction); // "forward" | "back" | "unknown"
console.log(nav.info); // data passed via navigation.navigate({ info })
}
});// In a React component
import { useRoute } from "@real-router/react";
import "@real-router/navigation-plugin";
function NavigationIndicator() {
const { route } = useRoute();
const nav = route?.context.navigation;
if (nav?.direction === "back") {
return <SlideRightTransition />;
}
return <SlideLeftTransition />;
}The plugin writes context in two hooks:
-
onTransitionStart: Writes captured metadata from the Navigation APInavigateevent (browser-initiated navigations) or from the constructor's activation priming (cross-document loads — see below) -
onTransitionSuccess: Writes or overwrites with the final metadata including derivednavigationTypeanddirection(all navigations)
Both writes are visible in subscribe() callbacks.
After a cross-document navigation (F5, browser back/forward across the JS context boundary, fresh URL bar entry, external link click), the plugin reads navigation.activation.navigationType once in its constructor and primes the metadata for the first transition. This makes state.context.navigation.navigationType correctly report "reload" after F5 and "traverse" after cross-document back/forward — values that the in-document navigate event handler cannot observe (the JS context that handled the outgoing navigation no longer exists).
Browser support: Chrome 123+, Edge 123+, Firefox 147+, Safari 26.2+ (Baseline 2026). On Chrome 102–122 the first transition falls back to "replace".
Limitations on the primed first transition:
-
userInitiatedis alwaysfalse(the browser does not expose F5 vs.location.reload()). -
directionis"forward"for"push","unknown"otherwise (the activation does not disclose back vs. forward for"traverse").
Use navigationPluginFactory when the Navigation API is available, fall back to browserPluginFactory otherwise:
import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const plugin =
"navigation" in globalThis
? navigationPluginFactory({ base })
: browserPluginFactory({ base });
router.usePlugin(plugin);If "navigation" in globalThis is false and no custom browser is injected, the factory throws immediately:
Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.
Router Navigation:
navigate() → Promise<State> → onTransitionSuccess → navigation.navigate()
Browser Navigate Event:
navigate event → handler → router.navigate() / navigateToNotFound() / $$error + intercept reject
The Navigation API serializes navigation via event.intercept() — only one navigation runs at a time. There's no deferred queue needed (unlike browser-plugin's popstate handling, which requires race condition protection).
All navigation methods return Promise<State>:
-
router.start(path?)— path is optional; the plugin injectsbrowser.getLocation()when no path is given -
router.navigate()— used in the navigate event handler withawaitand try/catch -
router.navigateToNotFound(path?)— called whenallowNotFound: trueand the URL doesn't match any route -
strict mode unmatched URL (
allowNotFound: false) — emits$$errorwithROUTE_NOT_FOUNDviaapi.emitTransitionError(), then rejectsevent.intercept()so the Navigation API auto-rolls back the URL. No silentnavigateToDefault()fallback.
-
URL Building:
buildUrlcorrectly addsbasepath -
URL Matching:
matchUrlparses URL via URL API, supports IPv6, Unicode -
Browser History:
pushfor new transitions,replacefor replacement - Navigate Event: Handles back/forward buttons with full lifecycle transition
-
Hash preservation (#532, tri-state default): Hash is preserved by default across any navigation — same path or different — when
opts.hashis omitted (undefined). Passopts.hash: ""to explicitly clear the fragment on a cross-path navigation, oropts.hash: "value"to set a new one. Section 6a documents the full tri-state contract. Prior to #532 (plugin v0.7.0) the heuristic dropped the hash on cross-path navigation; that behavior is gone. -
SSR safety: In a non-browser environment, the plugin falls back to no-ops via
createNavigationFallbackBrowser. No errors, methods return safe defaults. -
CANNOT_DEACTIVATE / RouterError URL sync: When a guard rejects (or the router raises any
RouterError—CANNOT_DEACTIVATE,CANNOT_ACTIVATE,SAME_STATES,CANCELLED), the plugin callssyncUrlToRouterStateinternally —browser.navigate(url, { history: "replace" })back to the router's current state. URL and router state stay in sync in a single visible transition.navigation.navigate().finishedresolves (URL is valid, pointing back to the previous route); rejection-interested callers observe the error via the router'sTRANSITION_ERROR/TRANSITION_CANCELevents. Manual sync is used instead of relying on Navigation API's native rollback on intercept rejection, which in Chromium headless and some cross-origin setups leaves a visible "committed-then-reverted" URL window that races with UI tests. Changed in plugin v0.6.0 (#524) — previouslyRouterErrorwas silently swallowed and URL/state desynchronized. -
Error recovery:
recoverFromNavigateErrorhandles non-RouterError exceptions (unexpected crashes) — logs[navigation-plugin] Critical error in navigate handlerand calls the samesyncUrlToRouterStatehelper. -
Router-driven mutations re-enter the navigate handler (#518, #580):
nav.navigate(...)andnav.traverseTo(...)fire navigate events. Chromium delivers them synchronously inside the call; Safari 26.2 WKWebView delivers them on a subsequent task. Detection is identity-based onevent.info:createNavigationBrowsertags every router-driven mutation withinfo: PLUGIN_SYNC_INFO(a stable string constant exported from the package), and the navigate-event handler checksevent.info === PLUGIN_SYNC_INFOto short-circuit with a noop intercept. The barereturnis not enough — per Navigation API spec, a same-origincanInterceptevent left un-intercepted triggers Chromium's cross-document fallback (full reload). The noopevent.intercept({ handler, scroll: "manual" })also overrides the Navigation API defaultscroll: "after-transition"so router-driven re-emits (scroll-spy hash-only nav, scroll-restoration URL sync) don't fight the app's own scroll motion — concrete bug closed: scroll-spy emit during a slow user scroll would otherwise trigger a viewport jump on every emit. Aligns with browser-plugin (History API has no auto-scroll on programmatic URL changes). Apps that want hash-anchor auto-scroll opt in viacreateScrollRestoration({ anchorScrolling: true }). Consumers supplying a customNavigationBrowsermust passPLUGIN_SYNC_INFOasinfoin their ownnav.navigate/nav.traverseTocalls. -
Same-URL guard in
onTransitionSuccess(#580): when the destination URL of a transition is canonically equal to the browser's current URL (initial transition into a route whose path already matches the bootstrap URL,router.navigate(name, params, { reload: true })to current state,forwardToredirects that don't change the path), the plugin writes router state viabrowser.updateCurrentEntry({ state })instead ofbrowser.navigate(url, { history: "replace" }). Both leave a single history entry, butupdateCurrentEntrydoes not fire a navigate event — avoiding both the Chromium event round-trip and a WKWebView quirk under custom protocols (tauri://,app://) where same-URLnav.navigate({history:"replace"})was treated as a cross-document navigation that discarded the JS context and triggered a render-loop on macOS 26.2 Tauri releases (#580). Behavioural consequence: same-URL transitions no longer fire navigate events. Consumers that branched on navigate events for state-only changes should userouter.subscribeinstead;state.context.navigation.navigationTypestill reflects the logical type (reload/replace). URL comparison uses theURLconstructor, soscheme://hostandscheme://host/compare equal. -
Missing state / unmatched URL: When
allowNotFound: true— callsnavigateToNotFound(browser.getLocation())to preserve the URL. WhenallowNotFound: false— emits$$errorwithROUTE_NOT_FOUNDviaapi.emitTransitionError()and throws in theevent.intercept()handler so the Navigation API auto-rolls back the URL. Router state is unchanged.defaultRouteis not consulted as an implicit fallback — see RouterOptions#allowNotFound for the userland migration snippet. -
Defensive entry URL parsing:
entryToStateand thetraverseToLastentry lookup delegate URL parsing tosafeParseUrl(viaextractPathFromAbsoluteUrl). Malformed entry URLs from mocks, extensions, or non-spec sources resolve toundefinedinstead of throwing from thenavigateevent handler — the Navigation API event proceeds through the "no matching route" branch. -
nav.navigateoptions forwarding:browser.navigate(url, options)forwards the fulloptionsobject to the Navigation API. Callers can passinfo,downloadRequest, and any future Navigation API options transparently.
entry.getState() = {
name: "users.view",
params: { id: "123" },
path: "/users/123",
};Navigation metadata is available on state.context.navigation after each transition -- not stored in the history entry itself.
The plugin uses two API levels with different baselines:
| Browser | Navigation API (navigation) |
navigation.activation priming (#531) |
|---|---|---|
| Chrome | 102+ | 123+ |
| Edge | 102+ | 123+ |
| Opera | 88+ | 109+ |
| Firefox | 147+ | 147+ |
| Safari | 26.2+ | 26.2+ |
-
Navigation API baseline (col. 2) —
globalThis.navigationplusevent.interceptare present. All core flows (URL sync,navigateevent handling, history extensions) work. Below this, the factory throws ("Navigation API is not supported") and you should fall back tobrowser-pluginvia Feature Detection. -
Activation priming baseline (col. 3) —
navigation.activation.navigationTypeis exposed. On these browsers the first transition after a cross-document load (F5, back/forward across pages, fresh URL bar entry) reports the correctstate.context.navigation.navigationType("reload","traverse","push","replace"). On the lower tier (Chrome 102–122, Opera 88–108),getActivationType()returnsundefinedand the first transition falls back to the legacy"replace"classification. Same-document navigation is unaffected. See §6b Cross-document load priming.
~89% global coverage as of 2026 for col. 2; the priming tier (col. 3) is Baseline 2026. For the remaining ~11%, use the Feature Detection pattern to fall back to browser-plugin.
Switching from browser-plugin to navigation-plugin is a one-line import change. Options are identical.
// Before
import { browserPluginFactory } from "@real-router/browser-plugin";
router.usePlugin(browserPluginFactory({ base: "/app" }));
// After
import { navigationPluginFactory } from "@real-router/navigation-plugin";
router.usePlugin(navigationPluginFactory({ base: "/app" }));All existing behavior is preserved. The exclusive extensions (peekBack, hasVisited, etc.) become available immediately after the swap.
Set forceDeactivate: false to respect canDeactivate guards on back/forward:
router.usePlugin(navigationPluginFactory({ forceDeactivate: false }));
import { getLifecycleApi } from "@real-router/core/api";
const lifecycle = getLifecycleApi(router);
lifecycle.addDeactivateGuard(
"checkout",
(router, getDep) => (toState, fromState) => {
return !hasUnsavedChanges(); // false blocks back/forward
},
);import { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const routes = [
{ name: "home", path: "/" },
{ name: "users", path: "/users" },
{ name: "users.view", path: "/view/:id" },
];
const router = createRouter(routes, {
defaultRoute: "home",
});
router.usePlugin(navigationPluginFactory());
await router.start();
// Navigation
await router.navigate("users.view", { id: "123" });
// Building URL
const url = router.buildUrl("users.view", { id: "123" });
// => "/users/view/123"
// Matching URL
const state = router.matchUrl("https://example.com/users/view/456");
// => { name: "users.view", params: { id: "456" }, ... }
// History inspection
const prev = router.peekBack();
const visited = router.getVisitedRoutes();import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
import { loggerPluginFactory } from "@real-router/logger-plugin";
import { persistentParamsPluginFactory } from "@real-router/persistent-params-plugin";
const routes = [
{ name: "home", path: "/" },
{ name: "users", path: "/users?page&sort" },
];
const router = createRouter(routes, {
defaultRoute: "home",
queryParamsMode: "default",
});
const base = "/app";
const plugin =
"navigation" in globalThis
? navigationPluginFactory({ base, forceDeactivate: false })
: browserPluginFactory({ base, forceDeactivate: false });
// Plugin order matters!
router.usePlugin(loggerPluginFactory());
router.usePlugin(persistentParamsPluginFactory(["lang", "theme"]));
router.usePlugin(plugin);
await router.start();- @real-router/browser-plugin — History API integration (broader browser support)
- @real-router/hash-plugin — Hash-based routing (no server config needed)
- Plugin Architecture — How plugins work in Real Router
-
Guards — Navigation guards and
canDeactivate -
Hash — URL fragment support via
state.context.url(this plugin is one of two URL plugins that claim it) -
Scroll Restoration —
state.transition.replaceskip path under this plugin (no magnetic snap on programmatic replaces) -
Scroll Spy —
event.intercept({ scroll: "manual" })override eliminates viewport jumps during scroll-spy emits
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key