* Creates a signal that tracks the resource snapshot and handles transactional behavior * (freezing during navigation and rollback recovery).
( source: Resource<T>, router: Router, injector: Injector, )
| 113 | * (freezing during navigation and rollback recovery). |
| 114 | */ |
| 115 | function createTransactionalSnapshot<T>( |
| 116 | source: Resource<T>, |
| 117 | router: Router, |
| 118 | injector: Injector, |
| 119 | ): { |
| 120 | snapshot: Signal<ResourceSnapshot<T>>; |
| 121 | frozenSnapshot: Signal<ResourceSnapshot<T> | null>; |
| 122 | } { |
| 123 | // Holds a snapshot of the resource to keep the UI masked (frozen) during pending navigations |
| 124 | // or while recovering from a cancelled navigation. |
| 125 | const frozenSnapshot = signal<ResourceSnapshot<T> | null>(null); |
| 126 | |
| 127 | // Tracks whether we are in a recovery phase after a cancelled navigation. |
| 128 | // The intended behavior is that on cancellation, the router reverts to the previous state. |
| 129 | // This reversion might trigger a new load of the previous state because the signal dependencies |
| 130 | // changed. If we were to release the frozen resource state immediately, the user would see a loading state |
| 131 | // for data they were just looking at. To avoid this "loading flash", we retain the frozen |
| 132 | // value (via frozenSnapshot) during this recovery load/reload until the resource settles. |
| 133 | const isRollbackRecoveryPending = signal(false); |
| 134 | |
| 135 | const sub = router.events.subscribe((e) => { |
| 136 | if (e instanceof NavigationStart) { |
| 137 | isRollbackRecoveryPending.set(false); |
| 138 | |
| 139 | if (frozenSnapshot() === null) { |
| 140 | // Freeze the snapshot at the start of navigation to keep the UI stable. |
| 141 | frozenSnapshot.set(source.snapshot()); |
| 142 | } |
| 143 | } else if (e instanceof NavigationEnd) { |
| 144 | // Navigation succeeded, so we can unfreeze and use the live state. |
| 145 | frozenSnapshot.set(null); |
| 146 | isRollbackRecoveryPending.set(false); |
| 147 | } else if (e instanceof NavigationSkipped) { |
| 148 | // If a navigation is skipped while we have a frozen snapshot (e.g. navigating to the |
| 149 | // current URL to cancel an in-flight navigation), the in-flight navigation is aborted |
| 150 | // and parameter rollback begins. We must maintain the frozen snapshot until the rollback |
| 151 | // recovery load completes to prevent flashing a loading state. |
| 152 | if (frozenSnapshot() !== null) { |
| 153 | isRollbackRecoveryPending.set(true); |
| 154 | } |
| 155 | } else if (e instanceof NavigationCancel || e instanceof NavigationError) { |
| 156 | const isRollback = |
| 157 | e instanceof NavigationError || |
| 158 | (e instanceof NavigationCancel && |
| 159 | e.code !== NavigationCancellationCode.SupersededByNewNavigation && |
| 160 | e.code !== NavigationCancellationCode.Redirect); |
| 161 | |
| 162 | if (!isRollback) return; |
| 163 | |
| 164 | isRollbackRecoveryPending.set(true); |
| 165 | } |
| 166 | }); |
| 167 | |
| 168 | injector.get(DestroyRef).onDestroy(() => sub.unsubscribe()); |
| 169 | |
| 170 | effect( |
| 171 | () => { |
| 172 | if (isRollbackRecoveryPending() && hasValueOrResolved(source)) { |
no test coverage detected