mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(router): add support for non-blocking router resources (#70211)
Introduces support for route-level resources via the `resources` property on route definitions, enabled with `withRouterResources()`. Router resources provide a reactive, signal-based alternative to resolvers, allowing routes to declare resources tied to route parameters and route lifecycle: - The `resources` function executes during navigation transitions within an injection context scoped to the route (`_localInjector`). - It receives a `ResourceContext` containing signals for `params`, `queryParams`, `fragment`, and `data`, alongside the static `snapshot`. - For newly created routes, `resources` runs once and attaches to `ActivatedRoute.resources`. For reused routes, parameter signals update reactively to trigger new data fetches while keeping resource references stable. - Wrapped resources (`routerResource`) provide transactional stability: snapshots are frozen during active navigations to prevent UI jitter, unfreezing on `NavigationEnd`. - On cancelled navigations or errors, rollback recovery retains the frozen snapshot until reverted signals settle, avoiding flashes of loading state. Manual reloads are rejected while frozen. - Local injectors are automatically cleaned up if navigations are cancelled or rolled back. Note that this commit only implements non-blocking resources (marked with `nonBlocking()`), deferring blocking resource resolution to future work to keep the initial changeset smaller and less complex. PR Close #70211
This commit is contained in:
committed by
Jessica Janiuk
parent
99c0f593c4
commit
80a04eba52
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dist/browser/main-[hash].js": 232419,
|
||||
"dist/browser/polyfills-[hash].js": 35726,
|
||||
"dist/browser/main-[hash].js": 237605,
|
||||
"dist/browser/polyfills-[hash].js": 35784,
|
||||
"dist/browser/event-dispatch-contract.min.js": 476
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"chunks": {
|
||||
"main": [
|
||||
"ACTIVATED_ROUTE_INJECTOR_FEATURE",
|
||||
"AFTER_RENDER_SEQUENCES_TO_ADD",
|
||||
"ANIMATIONS",
|
||||
"ANIMATION_QUEUE",
|
||||
@@ -243,6 +242,7 @@
|
||||
"ROUTER_CONFIGURATION",
|
||||
"ROUTER_OUTLET_DATA",
|
||||
"ROUTER_PRELOADER",
|
||||
"ROUTER_RESOURCES_FEATURE",
|
||||
"ROUTER_SCROLLER",
|
||||
"ROUTES",
|
||||
"ROUTES2",
|
||||
@@ -584,7 +584,6 @@
|
||||
"diPublicInInjector",
|
||||
"directiveHostEndFirstCreatePass",
|
||||
"directiveHostFirstCreatePass",
|
||||
"discardNewActivatedRoutes",
|
||||
"documentSupported",
|
||||
"domOnlyFirstCreatePass",
|
||||
"elementAttributeInternal",
|
||||
@@ -1037,6 +1036,7 @@
|
||||
"reportUnhandledError",
|
||||
"requiresRefreshOrTraversal",
|
||||
"resetConsumerBeforeComputation",
|
||||
"resetPendingRoutes",
|
||||
"resetPreOrderHookFlags",
|
||||
"resolveData",
|
||||
"resolveDirectives",
|
||||
@@ -1045,6 +1045,7 @@
|
||||
"resolveNode",
|
||||
"retrieveHydrationInfo",
|
||||
"reusedNodes",
|
||||
"rollbackState",
|
||||
"rootRoute",
|
||||
"runAfterLeaveAnimations",
|
||||
"runCanActivate",
|
||||
|
||||
@@ -41,7 +41,7 @@ function createNode(
|
||||
// reuse an activated route that is currently displayed on the screen
|
||||
if (prevState && routeReuseStrategy.shouldReuseRoute(curr.value, prevState.value.snapshot)) {
|
||||
const value = prevState.value;
|
||||
value._futureSnapshot = curr.value;
|
||||
value._setPending(curr.value);
|
||||
const children = createOrReuseChildren(routeReuseStrategy, curr, prevState, newlyCreatedRoutes);
|
||||
return new TreeNode<ActivatedRoute>(value, children);
|
||||
} else {
|
||||
@@ -50,7 +50,7 @@ function createNode(
|
||||
const detachedRouteHandle = routeReuseStrategy.retrieve(curr.value);
|
||||
if (detachedRouteHandle !== null) {
|
||||
const tree = (detachedRouteHandle as DetachedRouteHandleInternal).route;
|
||||
tree.value._futureSnapshot = curr.value;
|
||||
tree.value._setPending(curr.value);
|
||||
tree.children = curr.children.map((c) =>
|
||||
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
|
||||
);
|
||||
@@ -59,6 +59,7 @@ function createNode(
|
||||
}
|
||||
|
||||
const value = createActivatedRoute(curr.value);
|
||||
value._setPending(curr.value);
|
||||
newlyCreatedRoutes.add(value);
|
||||
const children = curr.children.map((c) =>
|
||||
createNode(routeReuseStrategy, c, undefined, newlyCreatedRoutes),
|
||||
|
||||
@@ -13,14 +13,63 @@ import {
|
||||
NgModuleFactory,
|
||||
Provider,
|
||||
ProviderToken,
|
||||
Signal,
|
||||
Type,
|
||||
Resource,
|
||||
} from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
export {DefaultExport} from '@angular/core';
|
||||
|
||||
import type {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
|
||||
import {ParamMap, Params} from './shared';
|
||||
import type {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
|
||||
|
||||
/**
|
||||
* The expected return type of a `resources` function.
|
||||
* @experimental
|
||||
*/
|
||||
export type ResourceResult = Record<string, Resource<unknown>>;
|
||||
|
||||
// Developer notes: properties are exposed as a plain Record (`Params`) rather than a `ParamMap`
|
||||
// to allow future type-check layers to infer exact keys (e.g., `{ id: string }`).
|
||||
// Same applies to data and queryparams.
|
||||
/**
|
||||
* The contextual information provided to a `resources` function.
|
||||
* @experimental
|
||||
*/
|
||||
export interface ResourceContext {
|
||||
/**
|
||||
* The matrix parameters of the route.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
params: Signal<Params>;
|
||||
/**
|
||||
* The query parameters of the route.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
queryParams: Signal<Params>;
|
||||
/**
|
||||
* The URL fragment.
|
||||
* @experimental
|
||||
*/
|
||||
fragment: Signal<string | null>;
|
||||
/**
|
||||
* Data provided in the route configuration.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
data: Signal<Record<string, any>>;
|
||||
/**
|
||||
* The static activated route snapshot for this navigation.
|
||||
* Useful for reading initial static configuration statically without
|
||||
* reacting to future parameter changes on reused routes.
|
||||
* @experimental
|
||||
*/
|
||||
snapshot: ActivatedRouteSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to handle a navigation request to the current URL. One of:
|
||||
*
|
||||
@@ -713,6 +762,14 @@ export interface Route {
|
||||
* @see [Resolve](guide/routing/data-resolvers#what-are-data-resolvers)
|
||||
*/
|
||||
resolve?: ResolveData;
|
||||
/**
|
||||
* A function that returns a record of resources.
|
||||
* This function is executed during the Main Loading Phase of a navigation.
|
||||
* @experimental
|
||||
* @internal
|
||||
*/
|
||||
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
|
||||
|
||||
/**
|
||||
* An array of child `Route` objects that specifies a nested route
|
||||
* configuration.
|
||||
|
||||
@@ -63,6 +63,7 @@ import {ActivateRoutes} from './operators/activate_routes';
|
||||
import {checkGuards} from './operators/check_guards';
|
||||
import {recognize} from './operators/recognize';
|
||||
import {resolveData} from './operators/resolve_data';
|
||||
import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
|
||||
import {switchTap} from './operators/switch_tap';
|
||||
import {TitleStrategy} from './page_title_strategy';
|
||||
import type {Router} from './router';
|
||||
@@ -83,7 +84,7 @@ import {UrlSerializer, UrlTree} from './url_tree';
|
||||
import {abortSignalToObservable} from './utils/abort_signal_to_observable';
|
||||
import {Checks, getAllRouteGuards} from './utils/preactivation';
|
||||
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
|
||||
import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
|
||||
import {TreeNode} from './utils/tree';
|
||||
|
||||
/**
|
||||
* @description
|
||||
@@ -765,11 +766,9 @@ export class NavigationTransitions {
|
||||
return of(t);
|
||||
}),
|
||||
|
||||
this.routerResourcesFeature?.operator() ?? ((t) => t),
|
||||
|
||||
this.routerResourcesFeature?.setupAndRunResources(abortController.signal) ?? ((t) => t),
|
||||
switchTap(() => this.afterPreactivation()),
|
||||
|
||||
// TODO(atscott): Move this into the last block below.
|
||||
switchMap(() => {
|
||||
const {currentSnapshot, targetSnapshot} = overallTransitionState;
|
||||
const viewTransitionStarted = this.createViewTransition?.(
|
||||
@@ -813,6 +812,8 @@ export class NavigationTransitions {
|
||||
return;
|
||||
}
|
||||
|
||||
resetPendingRoutes(t.targetRouterState);
|
||||
|
||||
completedOrAborted = true;
|
||||
this.currentNavigation.update((nav) => {
|
||||
(nav as Writable<Navigation>).abort = noop;
|
||||
@@ -832,7 +833,7 @@ export class NavigationTransitions {
|
||||
|
||||
takeUntil(
|
||||
abortSignalToObservable(abortController.signal).pipe(
|
||||
// Ignore aborts if we are already completed, canceled, or are in the activation stage (we have targetRouterState)
|
||||
// Ignore aborts if we are already completed, canceled, or the transition has entered the non-abortable activation stage
|
||||
filter(() => !completedOrAborted && abortable),
|
||||
tap(() => {
|
||||
this.cancelNavigationTransition(
|
||||
@@ -893,7 +894,7 @@ export class NavigationTransitions {
|
||||
}),
|
||||
catchError((e) => {
|
||||
completedOrAborted = true;
|
||||
discardNewActivatedRoutes(overallTransitionState);
|
||||
rollbackState(overallTransitionState);
|
||||
// If the application is already destroyed, the catch block should not
|
||||
// execute anything in practice because other resources have already
|
||||
// been released and destroyed.
|
||||
@@ -996,7 +997,7 @@ export class NavigationTransitions {
|
||||
reason: string,
|
||||
code: NavigationCancellationCode,
|
||||
) {
|
||||
discardNewActivatedRoutes(t);
|
||||
rollbackState(t);
|
||||
const navCancel = new NavigationCancel(
|
||||
t.id,
|
||||
this.urlSerializer.serialize(t.extractedUrl),
|
||||
@@ -1050,11 +1051,21 @@ export function isBrowserTriggeredNavigation(source: NavigationTrigger) {
|
||||
return source !== IMPERATIVE_NAVIGATION;
|
||||
}
|
||||
|
||||
function discardNewActivatedRoutes(t: NavigationTransition): void {
|
||||
if (!t.newlyCreatedRoutes) {
|
||||
function rollbackState(t: NavigationTransition): void {
|
||||
for (const r of t.newlyCreatedRoutes ?? []) {
|
||||
r._localInjector?.destroy();
|
||||
r._localInjector = undefined;
|
||||
}
|
||||
resetPendingRoutes(t.targetRouterState);
|
||||
}
|
||||
|
||||
function resetPendingRoutes(targetRouterState?: RouterState | null): void {
|
||||
if (!targetRouterState) {
|
||||
return;
|
||||
}
|
||||
for (const r of t.newlyCreatedRoutes) {
|
||||
r._localInjector?.destroy();
|
||||
}
|
||||
const traverse = (node: TreeNode<ActivatedRoute>) => {
|
||||
node.value.pending?.set(false);
|
||||
node.children.forEach(traverse);
|
||||
};
|
||||
traverse(targetRouterState._root);
|
||||
}
|
||||
|
||||
@@ -5,60 +5,145 @@
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {OperatorFunction} from 'rxjs';
|
||||
import {ActivatedRoute, ActivatedRouteSnapshot} from '../router_state';
|
||||
import {TreeNode} from '../utils/tree';
|
||||
import {createEnvironmentInjector, runInInjectionContext, Resource} from '@angular/core';
|
||||
import {OperatorFunction, pipe} from 'rxjs';
|
||||
import {ResourceContext, ResourceResult} from '../models';
|
||||
import {NavigationTransition} from '../navigation_transition';
|
||||
import {createEnvironmentInjector} from '@angular/core';
|
||||
import {tap} from 'rxjs/operators';
|
||||
import {ActivatedRoute, ActivatedRouteSnapshot, initializeActivatedRoute} from '../router_state';
|
||||
import {TreeNode} from '../utils/tree';
|
||||
import {BLOCKING_SYMBOL, InternalRouterResource, routerResource} from '../router_resource';
|
||||
import {switchTap} from './switch_tap';
|
||||
|
||||
export function setupActivatedRouteInjectors(): OperatorFunction<
|
||||
NavigationTransition,
|
||||
NavigationTransition
|
||||
> {
|
||||
return tap(({newlyCreatedRoutes, targetRouterState}) => {
|
||||
if (!newlyCreatedRoutes || !targetRouterState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Obviously the easier way would be to just iterate newlyCreatedRoutes
|
||||
// and create injectors for them. However, the feature will eventually
|
||||
// want to do things for routes that are being reused.
|
||||
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
|
||||
const route = stateNode.value;
|
||||
if (route) {
|
||||
processRoute(route, newlyCreatedRoutes);
|
||||
export function setupAndRunResources(
|
||||
abortSignal: AbortSignal,
|
||||
): OperatorFunction<NavigationTransition, NavigationTransition> {
|
||||
return pipe(
|
||||
switchTap(({newlyCreatedRoutes, targetRouterState}) => {
|
||||
if (!newlyCreatedRoutes || !targetRouterState) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childState of stateNode.children) {
|
||||
traverse(childState);
|
||||
}
|
||||
};
|
||||
const resourceSetupPromises: Array<Promise<void>> = [];
|
||||
|
||||
traverse(targetRouterState._root);
|
||||
});
|
||||
const traverse = (stateNode: TreeNode<ActivatedRoute>) => {
|
||||
const route = stateNode.value;
|
||||
if (route) {
|
||||
initializeActivatedRoute(route);
|
||||
processRoute(route, newlyCreatedRoutes, resourceSetupPromises, abortSignal);
|
||||
}
|
||||
|
||||
for (const childState of stateNode.children) {
|
||||
traverse(childState);
|
||||
}
|
||||
};
|
||||
|
||||
traverse(targetRouterState._root);
|
||||
|
||||
return Promise.all(resourceSetupPromises);
|
||||
// TODO: wait for blocking resources
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function processRoute(route: ActivatedRoute, newlyCreatedRoutes: Set<ActivatedRoute>) {
|
||||
// Only create injectors for routes with the feature enabled
|
||||
const useActivatedRouteInjector = (route?.routeConfig as any)?.ɵUseActivatedRouteInjector;
|
||||
if (!useActivatedRouteInjector) {
|
||||
function processRoute(
|
||||
route: ActivatedRoute,
|
||||
newlyCreatedRoutes: Set<ActivatedRoute>,
|
||||
resourceSetupPromises: Array<Promise<void>>,
|
||||
abortSignal: AbortSignal,
|
||||
) {
|
||||
const resources = route.routeConfig?.resources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newlyCreatedRoutes.has(route)) {
|
||||
setupNewActivatedRouteInjector(route._futureSnapshot, route);
|
||||
// This route is new. We need to run its resources function once.
|
||||
resourceSetupPromises.push(setupNewRouterResources(route._futureSnapshot, route, abortSignal));
|
||||
} else {
|
||||
// TODO: Do something with injectors that already exist
|
||||
updateExistingResources(route);
|
||||
}
|
||||
}
|
||||
|
||||
function setupNewActivatedRouteInjector(snapshot: ActivatedRouteSnapshot, route: ActivatedRoute) {
|
||||
if (ngDevMode && !!route._localInjector) {
|
||||
throw new Error(
|
||||
'invalid state: _localInjector should not exist on newly created ActivatedRoute yet',
|
||||
);
|
||||
async function setupNewRouterResources(
|
||||
snapshot: ActivatedRouteSnapshot,
|
||||
route: ActivatedRoute,
|
||||
abortSignal: AbortSignal,
|
||||
) {
|
||||
const resourcesFn = snapshot?.routeConfig?.resources;
|
||||
const parentInjector = snapshot?._environmentInjector;
|
||||
if (!resourcesFn || !parentInjector) {
|
||||
return;
|
||||
}
|
||||
|
||||
let childInjector = route._localInjector;
|
||||
if (!childInjector) {
|
||||
childInjector = createEnvironmentInjector([], parentInjector);
|
||||
route._localInjector = childInjector; // Attach to route for cleanup
|
||||
}
|
||||
|
||||
const context: ResourceContext = {
|
||||
params: route.paramsSignal,
|
||||
queryParams: route.queryParamsSignal,
|
||||
fragment: route.fragmentSignal,
|
||||
data: route.dataSignal,
|
||||
snapshot: route._futureSnapshot,
|
||||
};
|
||||
|
||||
const resourceResultRaw = runInInjectionContext(childInjector, () => resourcesFn(context));
|
||||
let resourceResult: ResourceResult;
|
||||
if (resourceResultRaw instanceof Promise) {
|
||||
resourceResult = await resourceResultRaw;
|
||||
// Bail out if the router cancelled the navigation (and destroyed our injector!)
|
||||
// while we were waiting.
|
||||
if (abortSignal.aborted) return;
|
||||
} else {
|
||||
resourceResult = resourceResultRaw as ResourceResult;
|
||||
}
|
||||
|
||||
if (!resourceResult) return;
|
||||
|
||||
const wrappedResult: ResourceResult = {};
|
||||
for (const [key, res] of Object.entries(resourceResult)) {
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
if (
|
||||
!res ||
|
||||
typeof res !== 'object' ||
|
||||
typeof (res as Partial<Resource<unknown>>).snapshot !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid resource returned for key "${key}". Expected a Resource, but got ${res === null ? 'null' : typeof res}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
wrappedResult[key] = runInInjectionContext(childInjector, () => routerResource(res));
|
||||
}
|
||||
|
||||
route.resources = route._futureSnapshot.resources = snapshot.resources = wrappedResult;
|
||||
prohibitBlockingResources(route, wrappedResult);
|
||||
}
|
||||
|
||||
function updateExistingResources(route: ActivatedRoute) {
|
||||
// This route is reused. We must eagerly update the resource context signals
|
||||
// so that resources can react and fetch new data during the pending navigation.
|
||||
const currentResources = route.snapshot?.resources;
|
||||
if (!currentResources) {
|
||||
return;
|
||||
}
|
||||
|
||||
route._futureSnapshot.resources = currentResources;
|
||||
prohibitBlockingResources(route, currentResources);
|
||||
}
|
||||
|
||||
function prohibitBlockingResources(route: ActivatedRoute, resourceResult: ResourceResult) {
|
||||
const childInjector = route._localInjector;
|
||||
if (!childInjector || !resourceResult) return;
|
||||
|
||||
for (const r of Object.values(resourceResult)) {
|
||||
const res = r as InternalRouterResource;
|
||||
if (res[BLOCKING_SYMBOL] === false) {
|
||||
continue;
|
||||
}
|
||||
throw new Error('blocking resources not implemented yet');
|
||||
}
|
||||
route._localInjector = createEnvironmentInjector([], snapshot._environmentInjector);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ export {RestoredState as ɵRestoredState} from './navigation_transition';
|
||||
export {loadChildren as ɵloadChildren} from './router_config_loader';
|
||||
export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module';
|
||||
export {afterNextNavigation as ɵafterNextNavigation} from './utils/navigations';
|
||||
export {withRouterResources as ɵwithActivatedRouteInjectors} from './provide_router';
|
||||
export {withRouterResources as ɵwithRouterResources} from './provide_router';
|
||||
export {nonBlocking as ɵnonBlocking} from './router_resource';
|
||||
export {ResourceContext as ɵResourceContext, ResourceResult as ɵResourceResult} from './models';
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
RouterConfigOptions,
|
||||
} from './router_config';
|
||||
import {ROUTES} from './router_config_loader';
|
||||
import {setupAndRunResources} from './operators/setup_and_run_resources';
|
||||
import {PreloadingStrategy, RouterPreloader} from './router_preloader';
|
||||
|
||||
import {ROUTER_SCROLLER, RouterScroller} from './router_scroller';
|
||||
@@ -64,7 +65,6 @@ import {
|
||||
ViewTransitionsFeatureOptions,
|
||||
} from './utils/view_transition';
|
||||
import {ROUTER_RESOURCES_FEATURE} from './router_resource_feature';
|
||||
import {setupActivatedRouteInjectors} from './operators/setup_and_run_resources';
|
||||
|
||||
/**
|
||||
* Sets up providers necessary to enable `Router` functionality for the application.
|
||||
@@ -363,8 +363,7 @@ export type EnabledBlockingInitialNavigationFeature =
|
||||
* @publicApi
|
||||
*/
|
||||
export type InitialNavigationFeature =
|
||||
| EnabledBlockingInitialNavigationFeature
|
||||
| DisabledInitialNavigationFeature;
|
||||
EnabledBlockingInitialNavigationFeature | DisabledInitialNavigationFeature;
|
||||
|
||||
/**
|
||||
* Configures initial navigation to start before the root component is created.
|
||||
@@ -888,14 +887,42 @@ export function withViewTransitions(
|
||||
return routerFeature(RouterFeatureKind.ViewTransitionsFeature, providers);
|
||||
}
|
||||
|
||||
export type RouterResourcesFeature =
|
||||
RouterFeature<RouterFeatureKind.ViewTransitionsFeature /* temporary - not public API. Must reuse existing */>;
|
||||
/**
|
||||
* A type alias for providers returned by `withRouterResources` for use with `provideRouter`.
|
||||
*
|
||||
* @see {@link withRouterResources}
|
||||
* @see {@link provideRouter}
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export type RouterResourcesFeature = RouterFeature<RouterFeatureKind.ViewTransitionsFeature>;
|
||||
|
||||
/**
|
||||
* Enables `resources` capabilities for Route definitions.
|
||||
*
|
||||
* @usageNotes
|
||||
*
|
||||
* Basic example of how you can enable the feature:
|
||||
* ```ts
|
||||
* const appRoutes: Routes = [];
|
||||
* bootstrapApplication(AppComponent,
|
||||
* {
|
||||
* providers: [
|
||||
* provideRouter(appRoutes, withRouterResources())
|
||||
* ]
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @experimental
|
||||
* @returns A set of providers for use with `provideRouter`.
|
||||
*/
|
||||
export function withRouterResources(): RouterResourcesFeature {
|
||||
const providers = [
|
||||
{
|
||||
provide: ROUTER_RESOURCES_FEATURE,
|
||||
useValue: {
|
||||
operator: setupActivatedRouteInjectors,
|
||||
setupAndRunResources,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ResourceSnapshot,
|
||||
effect,
|
||||
computed,
|
||||
WritableResource,
|
||||
assertInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import {Router} from './router';
|
||||
@@ -30,6 +29,28 @@ import {
|
||||
NavigationCancellationCode,
|
||||
} from './events';
|
||||
|
||||
export const BLOCKING_SYMBOL: unique symbol = Symbol(
|
||||
typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '',
|
||||
);
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface InternalRouterResource<T = unknown> extends Resource<T> {
|
||||
[BLOCKING_SYMBOL]?: boolean;
|
||||
reload(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a resource as non-blocking. The Router will NOT wait for this resource to resolve
|
||||
* before completing the navigation.
|
||||
* @experimental
|
||||
*/
|
||||
export function nonBlocking<T, R extends Resource<T>>(res: R): R {
|
||||
(res as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] = false;
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Resource to make it cooperative with the Angular Router, freezing its state
|
||||
* during navigation transitions and handling rollback recovery.
|
||||
@@ -45,7 +66,13 @@ export function routerResource<T>(source: Resource<T>): Resource<T> & {reload():
|
||||
injector,
|
||||
);
|
||||
|
||||
const res = resourceFromSnapshots(snapshotSignal) as Resource<T> & {reload(): boolean};
|
||||
const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource<T>;
|
||||
|
||||
if ((source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] === false) {
|
||||
res[BLOCKING_SYMBOL] = false;
|
||||
} else {
|
||||
res[BLOCKING_SYMBOL] = true;
|
||||
}
|
||||
|
||||
if (typeof (source as any).reload === 'function') {
|
||||
res.reload = function (): boolean {
|
||||
@@ -99,10 +126,18 @@ function createTransactionalSnapshot<T>(
|
||||
// Freeze the snapshot at the start of navigation to keep the UI stable.
|
||||
frozenSnapshot.set(source.snapshot());
|
||||
}
|
||||
} else if (e instanceof NavigationEnd || e instanceof NavigationSkipped) {
|
||||
// Navigation succeeded or was skipped, so we can unfreeze and use the live state.
|
||||
} else if (e instanceof NavigationEnd) {
|
||||
// Navigation succeeded, so we can unfreeze and use the live state.
|
||||
frozenSnapshot.set(null);
|
||||
isRollbackRecoveryPending.set(false);
|
||||
} else if (e instanceof NavigationSkipped) {
|
||||
// If a navigation is skipped while we have a frozen snapshot (e.g. navigating to the
|
||||
// current URL to cancel an in-flight navigation), the in-flight navigation is aborted
|
||||
// and parameter rollback begins. We must maintain the frozen snapshot until the rollback
|
||||
// recovery load completes to prevent flashing a loading state.
|
||||
if (frozenSnapshot() !== null) {
|
||||
isRollbackRecoveryPending.set(true);
|
||||
}
|
||||
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
|
||||
const isRollback =
|
||||
e instanceof NavigationError ||
|
||||
@@ -120,7 +155,12 @@ function createTransactionalSnapshot<T>(
|
||||
|
||||
effect(
|
||||
() => {
|
||||
if (isRollbackRecoveryPending() && !source.isLoading()) {
|
||||
if (
|
||||
isRollbackRecoveryPending() &&
|
||||
// TODO(consider): should this be hasValue || status !== loading
|
||||
// Some stream implementations may retain loading status after first item resolves
|
||||
!source.isLoading()
|
||||
) {
|
||||
isRollbackRecoveryPending.set(false);
|
||||
frozenSnapshot.set(null);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import {InjectionToken} from '@angular/core';
|
||||
import {OperatorFunction} from 'rxjs';
|
||||
import type {NavigationTransition} from './navigation_transition';
|
||||
|
||||
export interface RouterResourcesFeature {
|
||||
operator(): OperatorFunction<NavigationTransition, NavigationTransition>;
|
||||
import type {ActivatedRoute} from './router_state';
|
||||
|
||||
export interface RouterResourcesFeatureImplementation {
|
||||
setupAndRunResources(
|
||||
abortSignal: AbortSignal,
|
||||
): OperatorFunction<NavigationTransition, NavigationTransition>;
|
||||
}
|
||||
|
||||
export const ROUTER_RESOURCES_FEATURE = new InjectionToken<RouterResourcesFeature>(
|
||||
typeof ngDevMode === 'undefined' || ngDevMode ? 'Router resources feature' : '',
|
||||
export const ROUTER_RESOURCES_FEATURE = new InjectionToken<RouterResourcesFeatureImplementation>(
|
||||
typeof ngDevMode === 'undefined' || ngDevMode ? 'Router Resources Feature' : '',
|
||||
);
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {EnvironmentInjector, Type} from '@angular/core';
|
||||
import {
|
||||
computed,
|
||||
EnvironmentInjector,
|
||||
signal,
|
||||
Signal,
|
||||
Type,
|
||||
WritableSignal,
|
||||
ɵWritable as Writable,
|
||||
} from '@angular/core';
|
||||
import {BehaviorSubject, Observable, of} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
import {Data, ResolveData, Route} from './models';
|
||||
import {Data, ResourceResult, ResolveData, Route} from './models';
|
||||
import {convertToParamMap, ParamMap, Params, PRIMARY_OUTLET, RouteTitleKey} from './shared';
|
||||
import {equalSegments, UrlSegment} from './url_tree';
|
||||
import {shallowEqual, shallowEqualArrays} from './utils/collection';
|
||||
@@ -154,13 +162,45 @@ export class ActivatedRoute {
|
||||
/** An observable of the static and resolved data of this route. */
|
||||
public data: Observable<Data>;
|
||||
|
||||
// =================================
|
||||
// ===== Resource integration ======
|
||||
// =================================
|
||||
|
||||
// Note for framework developers: Unlike `data` and `params`, the `resources` property
|
||||
// is assigned once when the route is first initialized and its reference remains stable
|
||||
// for the entire lifetime of the `ActivatedRoute` instance. Do NOT replace or swap this
|
||||
// reference during pending navigations or route reuse, as doing so breaks reactivity
|
||||
// for components subscribed to the underlying resource signals.
|
||||
/**
|
||||
* Injector scoped to the lifetime of this ActivatedRoute object.
|
||||
* Created only when features tied to ActivatedRoute lifetime are used.
|
||||
* A map of resources for this route.
|
||||
*
|
||||
* @internal
|
||||
* @experimental
|
||||
* @internal don't expose while experimental
|
||||
*/
|
||||
resources?: ResourceResult;
|
||||
/** @internal */
|
||||
_localInjector?: EnvironmentInjector;
|
||||
/** @internal */
|
||||
readonly pending?: WritableSignal<boolean>;
|
||||
// Note for framework developers: `paramsSignal`, `queryParamsSignal`, and `dataSignal`
|
||||
// do NOT use `{equal: shallowEqual}`. Even though navigations can re-emit structurally
|
||||
// identical parameters, we rely on the default strict equality (`===`) so that any consumer
|
||||
// depending on the entire object is correctly invalidated. This encourages developers to properly
|
||||
// project nested scalar properties (e.g. `paramMap().get('id')`) instead of subscribing to the
|
||||
// naked object. Furthermore, `shallowEqual` unsafely relies on mutations (`.sort()`) for generic
|
||||
// arrays, making it unsuitable for signals that might carry complex `data` objects.
|
||||
/** @internal */
|
||||
readonly paramsSignal!: Signal<Params>;
|
||||
/** @internal */
|
||||
readonly queryParamsSignal!: Signal<Params>;
|
||||
/** @internal */
|
||||
readonly paramMapSignal!: Signal<ParamMap>;
|
||||
/** @internal */
|
||||
readonly queryParamMapSignal!: Signal<ParamMap>;
|
||||
/** @internal */
|
||||
readonly fragmentSignal!: Signal<string | null>;
|
||||
/** @internal */
|
||||
readonly dataSignal!: Signal<Data>;
|
||||
|
||||
/** @internal */
|
||||
constructor(
|
||||
@@ -244,6 +284,12 @@ export class ActivatedRoute {
|
||||
toString(): string {
|
||||
return this.snapshot ? this.snapshot.toString() : `Future(${this._futureSnapshot})`;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setPending(snapshot: ActivatedRouteSnapshot): void {
|
||||
this._futureSnapshot = snapshot;
|
||||
this.pending?.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParamsInheritanceStrategy = 'emptyOnly' | 'always';
|
||||
@@ -352,6 +398,12 @@ export class ActivatedRouteSnapshot {
|
||||
_queryParamMap?: ParamMap;
|
||||
/** @internal */
|
||||
readonly _environmentInjector: EnvironmentInjector;
|
||||
/**
|
||||
* The result of running the route's resources function.
|
||||
* @experimental
|
||||
* @internal
|
||||
*/
|
||||
resources?: ResourceResult;
|
||||
|
||||
/** The resolved route title */
|
||||
get title(): string | undefined {
|
||||
@@ -531,6 +583,41 @@ export function advanceActivatedRoute(route: ActivatedRoute): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeActivatedRoute(route: ActivatedRoute): void {
|
||||
if (route.paramsSignal !== undefined) {
|
||||
return;
|
||||
}
|
||||
const writableRoute = route as Writable<ActivatedRoute>;
|
||||
const pendingSignal = signal(false);
|
||||
writableRoute.pending = pendingSignal;
|
||||
|
||||
writableRoute.paramsSignal = computed(() =>
|
||||
pendingSignal() || !route.snapshot ? route._futureSnapshot.params : route.snapshot.params,
|
||||
);
|
||||
|
||||
writableRoute.queryParamsSignal = computed(() =>
|
||||
pendingSignal() || !route.snapshot
|
||||
? route._futureSnapshot.queryParams
|
||||
: route.snapshot.queryParams,
|
||||
);
|
||||
|
||||
writableRoute.paramMapSignal = computed(() => convertToParamMap(route.paramsSignal()));
|
||||
writableRoute.queryParamMapSignal = computed(() => convertToParamMap(route.queryParamsSignal()));
|
||||
|
||||
writableRoute.fragmentSignal = computed(() =>
|
||||
pendingSignal() || !route.snapshot ? route._futureSnapshot.fragment : route.snapshot.fragment,
|
||||
);
|
||||
|
||||
writableRoute.dataSignal = computed(() =>
|
||||
pendingSignal() || !route.snapshot ? route._futureSnapshot.data : route.snapshot.data,
|
||||
);
|
||||
|
||||
writableRoute._setPending = (snapshot: ActivatedRouteSnapshot) => {
|
||||
route._futureSnapshot = snapshot;
|
||||
pendingSignal.set(true);
|
||||
};
|
||||
}
|
||||
|
||||
export function equalParamsAndUrlSegments(
|
||||
a: ActivatedRouteSnapshot,
|
||||
b: ActivatedRouteSnapshot,
|
||||
|
||||
@@ -18,10 +18,23 @@ import {
|
||||
Router,
|
||||
destroyDetachedRouteHandle,
|
||||
provideRouter,
|
||||
ɵwithActivatedRouteInjectors,
|
||||
ɵwithRouterResources,
|
||||
ɵResourceContext as ResourceContext,
|
||||
ɵResourceResult as ResourceResult,
|
||||
} from '@angular/router';
|
||||
import {RouterTestingHarness} from '@angular/router/testing';
|
||||
|
||||
// TODO: Use the public @angular/router API once exposed
|
||||
type InternalRoute = Route & {
|
||||
/**
|
||||
* A function that returns a map of resources.
|
||||
* This function is executed during the Main Loading Phase of a navigation.
|
||||
* @experimental
|
||||
* @internal
|
||||
*/
|
||||
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
|
||||
};
|
||||
|
||||
describe('ActivatedRoute local injector', () => {
|
||||
@Component({
|
||||
template: 'home',
|
||||
@@ -63,10 +76,10 @@ describe('ActivatedRoute local injector', () => {
|
||||
let router: Router;
|
||||
let strategy: CustomReuseStrategy;
|
||||
|
||||
async function setUpRouter(routes: Route[]): Promise<RouterTestingHarness> {
|
||||
async function setUpRouter(routes: InternalRoute[]): Promise<RouterTestingHarness> {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(routes, ɵwithActivatedRouteInjectors()),
|
||||
provideRouter(routes, ɵwithRouterResources()),
|
||||
{provide: RouteReuseStrategy, useClass: CustomReuseStrategy},
|
||||
],
|
||||
});
|
||||
@@ -76,13 +89,13 @@ describe('ActivatedRoute local injector', () => {
|
||||
return await RouterTestingHarness.create();
|
||||
}
|
||||
|
||||
it('should create and destroy local injector for routes with ɵUseActivatedRouteInjector', async () => {
|
||||
it('should create and destroy local injector for routes with resources', async () => {
|
||||
const harness = await setUpRouter([
|
||||
{
|
||||
path: 'home',
|
||||
component: HomeComponent,
|
||||
'ɵUseActivatedRouteInjector': true,
|
||||
} as any,
|
||||
resources: () => ({}),
|
||||
},
|
||||
{
|
||||
path: 'away',
|
||||
component: AwayComponent,
|
||||
@@ -107,7 +120,7 @@ describe('ActivatedRoute local injector', () => {
|
||||
expect(destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT create local injector for routes without ɵUseActivatedRouteInjector', async () => {
|
||||
it('should NOT create local injector for routes without resources', async () => {
|
||||
const harness = await setUpRouter([
|
||||
{
|
||||
path: 'home',
|
||||
@@ -127,8 +140,8 @@ describe('ActivatedRoute local injector', () => {
|
||||
{
|
||||
path: 'home',
|
||||
component: HomeComponent,
|
||||
'ɵUseActivatedRouteInjector': true,
|
||||
} as any,
|
||||
resources: () => ({}),
|
||||
},
|
||||
{
|
||||
path: 'away',
|
||||
component: AwayComponent,
|
||||
@@ -186,13 +199,13 @@ describe('ActivatedRoute local injector', () => {
|
||||
{
|
||||
path: 'home',
|
||||
component: HomeComponent,
|
||||
'ɵUseActivatedRouteInjector': true,
|
||||
} as any,
|
||||
resources: () => ({}),
|
||||
},
|
||||
{
|
||||
path: 'throwing',
|
||||
component: ThrowingComponent,
|
||||
'ɵUseActivatedRouteInjector': true,
|
||||
} as any,
|
||||
resources: () => ({}),
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.navigateByUrl('/home');
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
EnvironmentProviders,
|
||||
resource,
|
||||
Resource,
|
||||
signal,
|
||||
ɵpromiseWithResolvers as promiseWithResolvers,
|
||||
} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
provideRouter as internalProvideRouter,
|
||||
Router,
|
||||
NavigationError,
|
||||
withNavigationErrorHandler,
|
||||
RedirectCommand,
|
||||
ɵwithRouterResources as withRouterResources,
|
||||
ɵnonBlocking as nonBlocking,
|
||||
ActivatedRoute,
|
||||
Route,
|
||||
ɵResourceContext as ResourceContext,
|
||||
ɵResourceResult as ResourceResult,
|
||||
RouterFeatures,
|
||||
} from '@angular/router';
|
||||
import {RouterTestingHarness} from '../testing';
|
||||
import {timeout, useAutoTick} from '../../private/testing/src/utils';
|
||||
import {rxResource} from '@angular/core/rxjs-interop';
|
||||
import {of} from 'rxjs';
|
||||
import {delay} from 'rxjs/operators';
|
||||
|
||||
// TODO: Use the public @angular/router API once exposed
|
||||
type InternalRoute = Route & {
|
||||
/**
|
||||
* A function that returns a map of resources.
|
||||
* This function is executed during the Main Loading Phase of a navigation.
|
||||
* @experimental
|
||||
* @internal
|
||||
*/
|
||||
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
|
||||
children?: InternalRoute[];
|
||||
};
|
||||
|
||||
export function provideRouter(
|
||||
routes: InternalRoute[],
|
||||
...features: RouterFeatures[]
|
||||
): EnvironmentProviders {
|
||||
return internalProvideRouter(routes, ...features);
|
||||
}
|
||||
|
||||
type ActivatedRouteInternal = ActivatedRoute & {
|
||||
resources?: {[key: string]: Resource<unknown>};
|
||||
};
|
||||
|
||||
describe('Router resources integration', () => {
|
||||
useAutoTick();
|
||||
|
||||
describe('Route Configuration and Execution', () => {
|
||||
it('should execute resources on initial navigation and expose the result', async () => {
|
||||
const loaderSpy = jasmine.createSpy('loader').and.resolveTo('loaded');
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: nonBlocking(resource({loader: loaderSpy})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test');
|
||||
await harness.fixture.whenStable();
|
||||
expect(loaderSpy).toHaveBeenCalled();
|
||||
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.value()).toBe('loaded');
|
||||
});
|
||||
|
||||
it('should support async resource functions returning a Promise', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
const loaderDeferred = promiseWithResolvers<string>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: async () => {
|
||||
const data = nonBlocking(
|
||||
resource({
|
||||
loader: async () => loaderDeferred.promise,
|
||||
}),
|
||||
);
|
||||
await timeout(10);
|
||||
return {data};
|
||||
},
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await router.navigateByUrl('/test');
|
||||
|
||||
expect(router.url).toBe('/test');
|
||||
|
||||
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
|
||||
const resourceRef = route?.resources?.['data'] as any;
|
||||
expect(resourceRef).toBeDefined();
|
||||
expect(resourceRef.isLoading()).toBe(true);
|
||||
expect(resourceRef.value()).toBeUndefined();
|
||||
|
||||
loaderDeferred.resolve('async loaded');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
expect(resourceRef.value()).toBe('async loaded');
|
||||
});
|
||||
|
||||
xit('should support async resource functions returning a Promise (blocking)', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: async () => {
|
||||
const data = resource({loader: async () => 'async loaded'});
|
||||
await timeout(10);
|
||||
return {data};
|
||||
},
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
const route = router.routerState.root.firstChild as ActivatedRouteInternal;
|
||||
const resourceRef = route?.resources?.['data'] as any;
|
||||
expect(resourceRef).toBeDefined();
|
||||
expect(resourceRef.value()).toBe('async loaded');
|
||||
});
|
||||
|
||||
it('should cleanly ignore resolution of async resource function if navigation was cancelled', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class FirstCmp {}
|
||||
@Component({standalone: true, template: ''})
|
||||
class SecondCmp {}
|
||||
|
||||
const firstResources = promiseWithResolvers<void>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'first',
|
||||
component: FirstCmp,
|
||||
resources: () => {
|
||||
const data = nonBlocking(resource({loader: async () => 'first data'}));
|
||||
return firstResources.promise.then(() => ({data}));
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'second',
|
||||
component: SecondCmp,
|
||||
resources: () => ({
|
||||
data: nonBlocking(resource({loader: async () => 'second data'})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
// Start navigation to /first (which will block waiting on firstResourcesPromise)
|
||||
const nav1 = harness.navigateByUrl('/first');
|
||||
await timeout(10);
|
||||
|
||||
// Supersede with navigation to /second
|
||||
await harness.navigateByUrl('/second');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/second');
|
||||
|
||||
// Now resolve the cancelled /first resources promise
|
||||
firstResources.resolve();
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
// Navigation should remain on /second
|
||||
expect(router.url).toBe('/second');
|
||||
});
|
||||
|
||||
xit('should cleanly ignore resolution of async resource function if navigation was cancelled (blocking)', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class FirstCmp {}
|
||||
@Component({standalone: true, template: ''})
|
||||
class SecondCmp {}
|
||||
|
||||
const firstResources = promiseWithResolvers<void>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'first',
|
||||
component: FirstCmp,
|
||||
resources: () => {
|
||||
const data = resource({loader: async () => 'first data'});
|
||||
return firstResources.promise.then(() => ({data}));
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'second',
|
||||
component: SecondCmp,
|
||||
resources: () => ({
|
||||
data: resource({loader: async () => 'second data'}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
// Start navigation to /first (which will block waiting on firstResourcesPromise)
|
||||
const nav1 = harness.navigateByUrl('/first');
|
||||
await timeout(10);
|
||||
|
||||
// Supersede with navigation to /second
|
||||
await harness.navigateByUrl('/second');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/second');
|
||||
|
||||
// Now resolve the cancelled /first resources promise
|
||||
firstResources.resolve();
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
// Navigation should remain on /second
|
||||
expect(router.url).toBe('/second');
|
||||
});
|
||||
|
||||
it('should not recreate and re-execute resources on subsequent navigations to the same route', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx) => ({
|
||||
data: nonBlocking(
|
||||
resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async () => {
|
||||
callCount++;
|
||||
return 'loaded';
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
await harness.navigateByUrl('/test/1');
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Navigating to the identical URL should not trigger a refetch
|
||||
await harness.navigateByUrl('/test/1');
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should support resources on componentless routes', async () => {
|
||||
@Component({standalone: true, template: '', selector: 'child-cmp-componentless'})
|
||||
class ChildCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'parent',
|
||||
resources: () => ({
|
||||
parentData: nonBlocking(resource({loader: async () => 'parent'})),
|
||||
}),
|
||||
children: [
|
||||
{
|
||||
path: 'componentless',
|
||||
resources: () => ({
|
||||
compData: nonBlocking(resource({loader: async () => 'comp'})),
|
||||
}),
|
||||
children: [{path: 'child', component: ChildCmp}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/parent/componentless/child');
|
||||
await harness.fixture.whenStable();
|
||||
await timeout(20);
|
||||
|
||||
const parentRoute = router.routerState.root.firstChild!;
|
||||
const componentlessRoute = parentRoute.firstChild!;
|
||||
|
||||
expect(
|
||||
((parentRoute as ActivatedRouteInternal).resources?.['parentData'] as any).value(),
|
||||
).toBe('parent');
|
||||
expect(
|
||||
((componentlessRoute as ActivatedRouteInternal).resources?.['compData'] as any).value(),
|
||||
).toBe('comp');
|
||||
});
|
||||
|
||||
it('should throw an error in dev mode if resource function does not return a Resource', async () => {
|
||||
@Component({standalone: true, template: '', selector: 'target-cmp-throw'})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: {foo: 'bar'} as any,
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
await expectAsync(harness.navigateByUrl('/test')).toBeRejectedWithError(
|
||||
/Invalid resource returned for key "data"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blocking vs Non-blocking Resources', () => {
|
||||
xit('should resolve resources before component initialization if blocking', async () => {
|
||||
let resolverSpy = jasmine.createSpy('resolver');
|
||||
const deferred = promiseWithResolvers<string>();
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TestCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TestCmp,
|
||||
resources: () => ({
|
||||
data: resource({
|
||||
loader: async () => {
|
||||
resolverSpy();
|
||||
return await deferred.promise;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
let completed = false;
|
||||
const navPromise = harness.navigateByUrl('/test').then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await timeout(10);
|
||||
expect(completed).toBe(false);
|
||||
expect(router.url).toBe('/');
|
||||
expect(resolverSpy).toHaveBeenCalled();
|
||||
|
||||
deferred.resolve('resolved');
|
||||
await navPromise;
|
||||
expect(completed).toBe(true);
|
||||
expect(router.url).toBe('/test');
|
||||
});
|
||||
|
||||
xit('should cancel navigation when blocking resource yields error', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: resource({
|
||||
loader: () => Promise.reject('test error'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test').catch(() => {});
|
||||
expect(router.url).not.toContain('/test');
|
||||
});
|
||||
|
||||
xit('should emit NavigationError when blocking resource rejects', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: resource({
|
||||
loader: () => Promise.reject('test error'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
const error = await new Promise((resolve) => {
|
||||
router.events.subscribe((e) => {
|
||||
if (e instanceof NavigationError) resolve(e.error);
|
||||
});
|
||||
harness.navigateByUrl('/test').catch(() => {});
|
||||
});
|
||||
expect(typeof error).toBe('object');
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
xit('should allow retrying a blocking route that previously threw an error', async () => {
|
||||
let shouldError = true;
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx) => ({
|
||||
data: resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async ({params}: any) => {
|
||||
if (shouldError) throw new Error('Failed');
|
||||
return params['id'];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
// First navigation fails
|
||||
await harness.navigateByUrl('/test/1').catch(() => {});
|
||||
expect(router.url).toBe('/'); // Cancelled
|
||||
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
// Retry the identical route with same parameters
|
||||
shouldError = false;
|
||||
await harness.navigateByUrl('/test/1');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/test/1'); // Succeeded!
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.value()).toBe('1');
|
||||
});
|
||||
|
||||
it('should complete navigation and expose error for non-blocking resources', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: nonBlocking(
|
||||
resource({
|
||||
loader: async () => {
|
||||
throw new Error('Non-blocking error');
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
// Non-blocking resource error doesn't cancel navigation
|
||||
await harness.navigateByUrl('/test');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/test');
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.error()?.message).toBe('Non-blocking error');
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Router Features', () => {
|
||||
it('should work with resolvers', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resolve: {id: () => '123'},
|
||||
resources: (ctx) => ({
|
||||
data: nonBlocking(
|
||||
resource({
|
||||
params: () => ctx.data(),
|
||||
loader: async ({params}: any) => ({name: `user ${params['id']}`}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test');
|
||||
await harness.fixture.whenStable();
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
|
||||
await timeout(20);
|
||||
expect(resourceRef.value()).toEqual({name: 'user 123'});
|
||||
});
|
||||
|
||||
it('should rollback parameter state on failed navigation', async () => {
|
||||
let canActivate = true;
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test/:id',
|
||||
component: TargetCmp,
|
||||
canActivate: [
|
||||
async () => {
|
||||
await timeout(10);
|
||||
return canActivate;
|
||||
},
|
||||
],
|
||||
resources: (ctx) => ({
|
||||
data: nonBlocking(
|
||||
resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async ({params}: any) => params['id'],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test/1');
|
||||
await harness.fixture.whenStable();
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.value()).toBe('1');
|
||||
|
||||
// Fail next navigation
|
||||
canActivate = false;
|
||||
await harness.navigateByUrl('/test/2');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
// The navigation is cancelled so the resource should retain the old value without loading flicker.
|
||||
expect(resourceRef.value()).toBe('1');
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
});
|
||||
|
||||
it('should abort previous request via AbortSignal when a new navigation comes in', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
const deferred = promiseWithResolvers<{name: string}>();
|
||||
let aborted = false;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'user/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx) => ({
|
||||
user: nonBlocking(
|
||||
resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async ({params, abortSignal}: any) => {
|
||||
abortSignal.addEventListener('abort', () => (aborted = true));
|
||||
if (params['id'] === '1') return deferred.promise;
|
||||
return {name: 'user 2'};
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
harness.navigateByUrl('/user/1');
|
||||
await timeout(10);
|
||||
|
||||
await harness.navigateByUrl('/user/2');
|
||||
await harness.fixture.whenStable();
|
||||
expect(aborted).toBe(true);
|
||||
|
||||
const userResource = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['user'] as any;
|
||||
expect(userResource?.value()).toEqual({name: 'user 2'});
|
||||
|
||||
// Resolving the old promise should have no effect
|
||||
deferred.resolve({name: 'user 1'});
|
||||
await timeout(10);
|
||||
expect(userResource.value()).toEqual({name: 'user 2'});
|
||||
});
|
||||
|
||||
xit('should correctly propagate parameter state when a pending navigation supersedes identically reused routes', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
const p2 = new Promise(() => {}); // never resolves
|
||||
const p3 = promiseWithResolvers<string>();
|
||||
|
||||
let loadedParams: any[] = [];
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx: any) => ({
|
||||
data: resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async ({params}: any) => {
|
||||
loadedParams.push(params['id']);
|
||||
if (params['id'] === '2') return p2;
|
||||
if (params['id'] === '3') return p3.promise;
|
||||
return params['id'];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test/1');
|
||||
|
||||
// Trigger nav2 and let it pend.
|
||||
harness.navigateByUrl('/test/2');
|
||||
await timeout(10);
|
||||
|
||||
// Supersede with identical route (/test/2 -> /test/3)
|
||||
const nav3 = harness.navigateByUrl('/test/3');
|
||||
await timeout(10);
|
||||
|
||||
expect(loadedParams).toEqual(['1', '2', '3']);
|
||||
|
||||
p3.resolve('loaded-3');
|
||||
await nav3;
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.value()).toBe('loaded-3');
|
||||
});
|
||||
|
||||
xit('should mask loading states during multi-step Guard UrlTree redirects', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
let loader = promiseWithResolvers<string>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'target/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx: any) => ({
|
||||
data: resource({
|
||||
params: () => ctx.params(),
|
||||
loader: async () => loader.promise,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'bad-link',
|
||||
canActivate: [() => TestBed.inject(Router).createUrlTree(['/target/3'])],
|
||||
component: TargetCmp,
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
// Settle initial state
|
||||
loader.resolve('1');
|
||||
await harness.navigateByUrl('/target/1');
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.value()).toBe('1');
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
|
||||
loader = promiseWithResolvers<string>();
|
||||
|
||||
// Initiate a navigation to a link that Redirects using a UrlTree Guard.
|
||||
const nav2 = harness.navigateByUrl('/bad-link');
|
||||
await timeout(50);
|
||||
|
||||
// UI is still masked looking like '1'
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
expect(resourceRef.value()).toBe('1');
|
||||
|
||||
loader.resolve('3');
|
||||
await nav2;
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
expect(resourceRef.value()).toBe('3');
|
||||
});
|
||||
|
||||
xit('should be able to redirect from a blocking resource using a NavigationErrorHandler', async () => {
|
||||
let handleCount = 0;
|
||||
let errorRef: unknown = null;
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
@Component({standalone: true, template: ''})
|
||||
class ErrorCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'test',
|
||||
component: TargetCmp,
|
||||
resources: () => ({
|
||||
data: resource({
|
||||
loader: async () => {
|
||||
throw new Error('Resource failed!');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'error',
|
||||
component: ErrorCmp,
|
||||
},
|
||||
],
|
||||
withNavigationErrorHandler((e: NavigationError) => {
|
||||
handleCount++;
|
||||
errorRef = e.error;
|
||||
return new RedirectCommand(TestBed.inject(Router).parseUrl('/error'));
|
||||
}),
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await harness.navigateByUrl('/test');
|
||||
|
||||
expect(router.url).toBe('/error');
|
||||
expect(handleCount).toBe(1);
|
||||
expect((errorRef as Error).message).toBe('Resource failed!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rxResource Integration', () => {
|
||||
xit('should successfully wrap and await an rxResource', async () => {
|
||||
@Component({standalone: true, template: ''})
|
||||
class TargetCmp {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(
|
||||
[
|
||||
{
|
||||
path: 'rx/:id',
|
||||
component: TargetCmp,
|
||||
resources: (ctx) => ({
|
||||
data: rxResource({
|
||||
params: () => ctx.params(),
|
||||
stream: ({params}: any) => of(`rx loaded ${params['id']}`).pipe(delay(10)),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
withRouterResources(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
const nav = harness.navigateByUrl('/rx/123');
|
||||
await timeout(5);
|
||||
|
||||
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
|
||||
?.resources?.['data'] as any;
|
||||
expect(resourceRef.isLoading()).toBe(true);
|
||||
expect(resourceRef.value()).toBeUndefined();
|
||||
|
||||
await nav;
|
||||
await harness.fixture.whenStable();
|
||||
|
||||
expect(resourceRef.isLoading()).toBe(false);
|
||||
expect(resourceRef.value()).toBe('rx loaded 123');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user