Skip to content

Commit 2c522ef

Browse files
lizclipseNothingEverHappens
authored andcommitted
fix(core): fix change tracking for Resource#hasValue (#62595)
When using `hasValue()` I would expect it to behave like any other reactive value such that changes to the internal `value()` that do not cause `hasValue()` to return anything different do not trigger change detection, but this was not the case. This change wraps the value checking in a `computed` such that it behaves as expected again while still preserving the type narrowing. PR Close #62595
1 parent ba8a5d8 commit 2c522ef

File tree

2 files changed

+46
-4
lines changed

2 files changed

+46
-4
lines changed

packages/core/src/resource/resource.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,19 @@ abstract class BaseWritableResource<T> implements WritableResource<T> {
130130

131131
readonly isLoading = computed(() => this.status() === 'loading' || this.status() === 'reloading');
132132

133-
hasValue(): this is ResourceRef<Exclude<T, undefined>> {
134-
// Note: we specifically read `isError()` instead of `status()` here to avoid triggering
135-
// reactive consumers which read `hasValue()`. This way, if `hasValue()` is used inside of an
136-
// effect, it doesn't cause the effect to rerun on every status change.
133+
// Use a computed here to avoid triggering reactive consumers if the value changes while staying
134+
// either defined or undefined.
135+
private readonly isValueDefined = computed(() => {
136+
// Check if it's in an error state first to prevent the error from bubbling up.
137137
if (this.isError()) {
138138
return false;
139139
}
140140

141141
return this.value() !== undefined;
142+
});
143+
144+
hasValue(): this is ResourceRef<Exclude<T, undefined>> {
145+
return this.isValueDefined();
142146
}
143147

144148
asReadonly(): Resource<T> {

packages/core/test/resource/resource_spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,44 @@ describe('resource', () => {
236236
expect(effectRuns).toBe(1);
237237
});
238238

239+
it('should not trigger consumers on every value change via hasValue()', async () => {
240+
const testResource = resource<number | undefined, unknown>({
241+
loader: () => Promise.resolve(undefined),
242+
injector: TestBed.inject(Injector),
243+
});
244+
245+
let effectRuns = 0;
246+
effect(
247+
() => {
248+
testResource.hasValue();
249+
effectRuns++;
250+
},
251+
{injector: TestBed.inject(Injector)},
252+
);
253+
254+
TestBed.tick();
255+
// Starts off without a value
256+
expect(testResource.hasValue()).toBeFalse();
257+
// Effect should run the first time.
258+
expect(effectRuns).toBe(1);
259+
260+
// Set value to something defined
261+
testResource.set(0);
262+
TestBed.tick();
263+
// Value is now defined
264+
expect(testResource.hasValue()).toBeTrue();
265+
// The effect should have been re-run.
266+
expect(effectRuns).toBe(2);
267+
268+
// Set value to something else defined
269+
testResource.set(1);
270+
TestBed.tick();
271+
// Value is still defined
272+
expect(testResource.hasValue()).toBeTrue();
273+
// The effect should not rerun.
274+
expect(effectRuns).toBe(2);
275+
});
276+
239277
it('should update computed signals', async () => {
240278
const backend = new MockEchoBackend();
241279
const counter = signal(0);

0 commit comments

Comments
 (0)