Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 1d6c77f

Browse files
authored
Merge pull request #1571 from eneajaho/untrack-push
fix(template): untrack subscription and unsubscription in push pipe
2 parents 944a489 + 5978a15 commit 1d6c77f

File tree

2 files changed

+70
-20
lines changed

2 files changed

+70
-20
lines changed

libs/template/push/src/lib/push.pipe.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
OnDestroy,
55
Pipe,
66
PipeTransform,
7+
untracked,
78
} from '@angular/core';
89
import {
910
RxStrategyNames,
@@ -165,7 +166,7 @@ export class RxPush implements PipeTransform, OnDestroy {
165166

166167
/** @internal */
167168
ngOnDestroy(): void {
168-
this.subscription?.unsubscribe();
169+
untracked(() => this.subscription?.unsubscribe());
169170
}
170171

171172
/** @internal */
@@ -179,26 +180,38 @@ export class RxPush implements PipeTransform, OnDestroy {
179180
private handleChangeDetection(): Unsubscribable {
180181
const scope = (this.cdRef as any).context;
181182
const sub = new Subscription();
182-
const setRenderedValue = this.templateValues$.subscribe(({ value }) => {
183-
this.renderedValue = value;
184-
});
185-
const render = this.hasInitialValue(this.templateValues$)
186-
.pipe(
187-
switchMap((isSync) =>
188-
this.templateValues$.pipe(
189-
// skip ticking change detection
190-
// in case we have an initial value, we don't need to perform cd
191-
// the variable will be evaluated anyway because of the lifecycle
192-
skip(isSync ? 1 : 0),
193-
// onlyValues(),
194-
this.render(scope),
195-
tap((v) => {
196-
this._renderCallback?.next(v);
197-
})
183+
184+
// Subscription can be side-effectful, and we don't want any signal reads which happen in the
185+
// side effect of the subscription to be tracked by a component's template when that
186+
// subscription is triggered via the async pipe. So we wrap the subscription in `untracked` to
187+
// decouple from the current reactive context.
188+
//
189+
// `untracked` also prevents signal _writes_ which happen in the subscription side effect from
190+
// being treated as signal writes during the template evaluation (which throws errors).
191+
const setRenderedValue = untracked(() =>
192+
this.templateValues$.subscribe(({ value }) => {
193+
this.renderedValue = value;
194+
})
195+
);
196+
const render = untracked(() =>
197+
this.hasInitialValue(this.templateValues$)
198+
.pipe(
199+
switchMap((isSync) =>
200+
this.templateValues$.pipe(
201+
// skip ticking change detection
202+
// in case we have an initial value, we don't need to perform cd
203+
// the variable will be evaluated anyway because of the lifecycle
204+
skip(isSync ? 1 : 0),
205+
// onlyValues(),
206+
this.render(scope),
207+
tap((v) => {
208+
this._renderCallback?.next(v);
209+
})
210+
)
198211
)
199212
)
200-
)
201-
.subscribe();
213+
.subscribe()
214+
);
202215
sub.add(setRenderedValue);
203216
sub.add(render);
204217
return sub;

libs/template/push/src/lib/tests/push.pipe.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChangeDetectorRef, Component } from '@angular/core';
1+
import { ChangeDetectorRef, Component, computed, signal } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import {
44
RX_RENDER_STRATEGIES_CONFIG,
@@ -29,12 +29,14 @@ let pushPipeTestComponent: {
2929
};
3030
let componentNativeElement: HTMLElement;
3131
let strategyProvider: RxStrategyProvider;
32+
let pushPipe: RxPush;
3233

3334
const setupPushPipeComponent = () => {
3435
TestBed.configureTestingModule({
3536
declarations: [PushPipeTestComponent],
3637
imports: [RxPush],
3738
providers: [
39+
RxPush,
3840
ChangeDetectorRef,
3941
{
4042
provide: RX_RENDER_STRATEGIES_CONFIG,
@@ -61,8 +63,43 @@ const setupPushPipeComponent = () => {
6163
pushPipeTestComponent = fixturePushPipeTestComponent.componentInstance;
6264
componentNativeElement = fixturePushPipeTestComponent.nativeElement;
6365
strategyProvider = TestBed.inject(RxStrategyProvider);
66+
pushPipe = TestBed.inject(RxPush);
6467
};
6568

69+
describe('RxPush', () => {
70+
beforeAll(() => mockConsole());
71+
beforeEach(setupPushPipeComponent);
72+
73+
it('should be instantiable', () => {
74+
expect(pushPipe).toBeDefined();
75+
});
76+
77+
describe('transform function', () => {
78+
it('should not track signal reads in subscriptions', () => {
79+
const trigger = signal(false);
80+
81+
const obs = new Observable(() => {
82+
// Whenever `obs` is subscribed, synchronously read `trigger`.
83+
trigger();
84+
});
85+
86+
let trackCount = 0;
87+
const tracker = computed(() => {
88+
// Subscribe to `obs` within this `computed`. If the subscription side effect runs
89+
// within the computed, then changes to `trigger` will invalidate this computed.
90+
pushPipe.transform(obs);
91+
92+
// The computed returns how many times it's run.
93+
return ++trackCount;
94+
});
95+
96+
expect(tracker()).toBe(1);
97+
trigger.set(true);
98+
expect(tracker()).toBe(1);
99+
});
100+
});
101+
});
102+
66103
describe('RxPush used as pipe in the template', () => {
67104
beforeAll(() => mockConsole());
68105

0 commit comments

Comments
 (0)