| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent e01573f commit 50e599e
2 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -9,6 +9,7 @@ | |||
| 9 | 9 | import {assertInInjectionContext, inject, Injector} from '../di'; | |
| 10 | 10 | import {DestroyRef} from '../linker'; | |
| 11 | 11 | import {effect} from '../render3/reactivity/effect'; | |
| 12 | + import {linkedSignal} from '../render3/reactivity/linked_signal'; | ||
| 12 | 13 | import {signal} from '../render3/reactivity/signal'; | |
| 13 | 14 | import {untracked} from '../render3/reactivity/untracked'; | |
| 14 | 15 | import {Resource, ResourceSnapshot, type DebouncedOptions} from './api'; | |
@@ -43,23 +44,41 @@ export function debounced<T>( | |||
| 43 | 44 | } | |
| 44 | 45 | const injector = options?.injector ?? inject(Injector); | |
| 45 | 46 | ||
| 46 | - const state = signal<ResourceSnapshot<T>>({ | ||
| 47 | - status: 'resolved', | ||
| 48 | - value: untracked(() => { | ||
| 47 | + let active: Promise<void> | void | undefined; | ||
| 48 | + let pendingValue: T | undefined; | ||
| 49 | + | ||
| 50 | + injector.get(DestroyRef).onDestroy(() => { | ||
| 51 | + active = undefined; | ||
| 52 | + }); | ||
| 53 | + | ||
| 54 | + const state = linkedSignal< | ||
| 55 | + {value: T; thrown: false} | {error: unknown; thrown: true}, | ||
| 56 | + ResourceSnapshot<T> | ||
| 57 | + >({ | ||
| 58 | + source: () => { | ||
| 49 | 59 | try { | |
| 50 | 60 | setInParamsFunction(true); | |
| 51 | - return source(); | ||
| 61 | + return {value: source(), thrown: false}; | ||
| 62 | + } catch (err) { | ||
| 63 | + rethrowFatalErrors(err); | ||
| 64 | + return {error: err, thrown: true}; | ||
| 52 | 65 | } finally { | |
| 53 | 66 | setInParamsFunction(false); | |
| 54 | 67 | } | |
| 55 | - }), | ||
| 56 | - }); | ||
| 57 | - | ||
| 58 | - let active: Promise<void> | void | undefined; | ||
| 59 | - let pendingValue: T | undefined; | ||
| 68 | + }, | ||
| 69 | + computation: (res, previous) => { | ||
| 70 | + // If we already have a state from the effect or a previous read, keep it! | ||
| 71 | + // The effect is responsible for timing and state transitions. | ||
| 72 | + if (previous !== undefined) { | ||
| 73 | + return previous.value; | ||
| 74 | + } | ||
| 60 | 75 | ||
| 61 | - injector.get(DestroyRef).onDestroy(() => { | ||
| 62 | - active = undefined; | ||
| 76 | + // On the very first evaluation, determine the initial state synchronously. | ||
| 77 | + if (res.thrown) { | ||
| 78 | + return {status: 'error', error: res.error as Error}; | ||
| 79 | + } | ||
| 80 | + return {status: 'resolved', value: res.value}; | ||
| 81 | + }, | ||
| 63 | 82 | }); | |
| 64 | 83 | ||
| 65 | 84 | effect( | |
@@ -82,7 +101,7 @@ export function debounced<T>( | |||
| 82 | 101 | ||
| 83 | 102 | // Check if the value is the same as the previous one. | |
| 84 | 103 | const equal = options?.equal ?? Object.is; | |
| 85 | - if (currentState.status === 'reloading') { | ||
| 104 | + if (currentState.status === 'reloading' || currentState.status === 'loading') { | ||
| 86 | 105 | if (equal(value, pendingValue!)) return; | |
| 87 | 106 | } else if (currentState.status === 'resolved') { | |
| 88 | 107 | if (equal(value, currentState.value!)) return; | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,59 @@ | |||
| 1 | + /** | ||
| 2 | + * @license | ||
| 3 | + * Copyright Google LLC All Rights Reserved. | ||
| 4 | + * | ||
| 5 | + * Use of this source code is governed by an MIT-style license that can be | ||
| 6 | + * found in the LICENSE file at https://angular.dev/license | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | + import {ChangeDetectionStrategy, Component, debounced, resource, signal} from '@angular/core'; | ||
| 10 | + import {TestBed} from '@angular/core/testing'; | ||
| 11 | + | ||
| 12 | + import {form, FormField, validateAsync} from '../../public_api'; | ||
| 13 | + | ||
| 14 | + describe('debounced inside validateAsync bug', () => { | ||
| 15 | + it('should not throw a cycle error when using debounced in validateAsync factory', async () => { | ||
| 16 | + @Component({ | ||
| 17 | + selector: 'debounce-bug', | ||
| 18 | + changeDetection: ChangeDetectionStrategy.OnPush, | ||
| 19 | + template: ` <input [formField]="form.hello" /> `, | ||
| 20 | + imports: [FormField], | ||
| 21 | + }) | ||
| 22 | + class DebounceBug { | ||
| 23 | + protected readonly model = signal({ | ||
| 24 | + hello: 'world', | ||
| 25 | + }); | ||
| 26 | + | ||
| 27 | + protected readonly form = form(this.model, (path) => { | ||
| 28 | + validateAsync(path.hello, { | ||
| 29 | + params: ({value}) => value(), | ||
| 30 | + factory: (params) => { | ||
| 31 | + const debounce = debounced(params, 300); | ||
| 32 | + return resource({ | ||
| 33 | + params: ({chain}) => chain(debounce), | ||
| 34 | + loader: async ({params}) => { | ||
| 35 | + return new Promise<string>((resolve) => | ||
| 36 | + setTimeout(() => { | ||
| 37 | + resolve('hi'); | ||
| 38 | + }, 400), | ||
| 39 | + ); | ||
| 40 | + }, | ||
| 41 | + }); | ||
| 42 | + }, | ||
| 43 | + onSuccess: (response) => null, | ||
| 44 | + onError: (error) => null, | ||
| 45 | + }); | ||
| 46 | + }); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + const fixture = TestBed.createComponent(DebounceBug); | ||
| 50 | + fixture.detectChanges(); | ||
| 51 | + await fixture.whenStable(); | ||
| 52 | + | ||
| 53 | + // In a "zoneless and async-first" testing environment, just need to change something and wait | ||
| 54 | + const input = fixture.nativeElement.querySelector('input'); | ||
| 55 | + input.value = 'hello!'; | ||
| 56 | + input.dispatchEvent(new Event('input')); | ||
| 57 | + await fixture.whenStable(); | ||
| 58 | + }); | ||
| 59 | + }); | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments