Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/router/src/router_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @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 {
inject,
Injector,
Resource,
resourceFromSnapshots,
Signal,
signal,
DestroyRef,
ResourceSnapshot,
effect,
computed,
WritableResource,
assertInInjectionContext,
} from '@angular/core';
import {Router} from './router';
import {
NavigationStart,
NavigationEnd,
NavigationCancel,
NavigationError,
NavigationSkipped,
NavigationCancellationCode,
} from './events';

/**
* Wraps a Resource to make it cooperative with the Angular Router, freezing its state
* during navigation transitions and handling rollback recovery.
*/
export function routerResource<T>(source: Resource<T>): Resource<T> & {reload(): boolean} {
ngDevMode && assertInInjectionContext(routerResource);
const injector = inject(Injector);
Comment thread
atscott marked this conversation as resolved.
const router = injector.get(Router);

const {snapshot: snapshotSignal, frozenSnapshot} = createTransactionalSnapshot(
source,
router,
injector,
);

const res = resourceFromSnapshots(snapshotSignal) as Resource<T> & {reload(): boolean};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Side conversation, this reminds me of #67124. We might want to have such feature in the public api.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yea, it's a bit of that which seems to also be a consequence of Resource not having reload. It's only on WritableResource but it feels to me like reload is quite different from the set of WritableResource and could arguably be in the base Resource interface


res.reload = function (): boolean {
if (frozenSnapshot() !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you leave a comment explaining why a non-null frozen snapshot means we can't/don't reload?

I'm guessing it's this:

Cooperative API Surface: It omits writable APIs (set/update) to prevent out-of-band mutations that bypass the transactional freeze, and only exposes a minification-safe reload() method that is disabled during active navigations.

return false;
}
return (source as WritableResource<T>).reload?.() ?? false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the source is fixed, would it make sense to move the check for whether it supports reload() outside the callback?

if (isWritableResource(source)) {
  res.reload = () => frozenSnapshot() !== null && source.reload();
} else {
  res.reload = () => false;
}

Since the always false branch is stateless you could also avoid the closure allocation (not sure how commonly a resource won't be writable though).

};

return res;
}

/**
* Creates a signal that tracks the resource snapshot and handles transactional behavior
* (freezing during navigation and rollback recovery).
*/
function createTransactionalSnapshot<T>(
source: Resource<T>,
router: Router,
injector: Injector,
): {
snapshot: Signal<ResourceSnapshot<T>>;
frozenSnapshot: Signal<ResourceSnapshot<T> | null>;
} {
// Holds a snapshot of the resource to keep the UI masked (frozen) during pending navigations
// or while recovering from a cancelled navigation.
const frozenSnapshot = signal<ResourceSnapshot<T> | null>(null);

// Tracks whether we are in a recovery phase after a cancelled navigation.
// The intended behavior is that on cancellation, the router reverts to the previous state.
// This reversion might trigger a new load of the previous state because the signal dependencies
// changed. If we were to release the frozen resource state immediately, the user would see a loading state
// for data they were just looking at. To avoid this "loading flash", we retain the frozen
// value (via frozenSnapshot) during this recovery load/reload until the resource settles.
const isRollbackRecoveryPending = signal(false);

const sub = router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
isRollbackRecoveryPending.set(false);

if (frozenSnapshot() === null) {
// 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.
frozenSnapshot.set(null);
isRollbackRecoveryPending.set(false);
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
const isRollback =
e instanceof NavigationError ||
(e instanceof NavigationCancel &&
e.code !== NavigationCancellationCode.SupersededByNewNavigation &&
e.code !== NavigationCancellationCode.Redirect);

if (!isRollback) return;

const frozen = frozenSnapshot();

// Because `rollbackState` runs synchronously immediately prior to `NavigationCancel` (for true rollbacks),
// the underlying resource parameters have already reverted.
// If those parameters triggered a reload, `isLoading` will synchronously remain true here.
if (frozen?.status === 'resolved' || frozen?.status === 'reloading') {
// We were in a valid state, so keep the UI frozen while we wait for the recovery load to complete.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And when that recovery completes isLoading() will update and trigger the effect below?

isRollbackRecoveryPending.set(true);
} else {
// We were not in a valid state, so we can't recover. Unfreeze immediately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably just a knowledge deficit on my part, but what happens (a) to get in this state and (b) to recover from it?

isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
}
});

injector.get(DestroyRef).onDestroy(() => sub.unsubscribe());

effect(
() => {
if (isRollbackRecoveryPending() && !source.isLoading()) {
isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
},
{injector},
);

return {
snapshot: computed(() => frozenSnapshot() ?? source.snapshot()),
frozenSnapshot,
};
}
Loading
Loading