Skip to content

Commit fe1edeb

Browse files
authored
fix(store): avoid NG0205/ObjectUnsubscribedError during TestBed teardown (#2480)
The bootstrap-completion check was rewritten in #2440 to use `toObservable` + `takeUntilDestroyed`, which keeps an internal effect alive until the root injector is destroyed. During fast TestBed teardown this raced with other root-scoped cleanup (like the state stream completing itself), throwing NG0205 or ObjectUnsubscribedError after tests had already passed. Reverted to a self-destroying `effect()` that detaches itself the moment it fires, so nothing is left registered when the injector is torn down. Fixes #2479
1 parent ac50812 commit fe1edeb

2 files changed

Lines changed: 53 additions & 10 deletions

File tree

packages/store/src/internal/lifecycle-state-manager.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { DestroyRef, inject, Injectable, Injector } from '@angular/core';
2-
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
1+
import { DestroyRef, effect, inject, Injectable, Injector } from '@angular/core';
32
import { ɵNGXS_APP_BOOTSTRAP_STATE } from '@ngxs/store/internals';
43
import { getValue, InitState, UpdateState } from '@ngxs/store/plugins';
54

@@ -12,7 +11,6 @@ import { getInvalidInitializationOrderMessage } from '../configs/messages.config
1211

1312
@Injectable({ providedIn: 'root' })
1413
export class LifecycleStateManager {
15-
private _destroyRef = inject(DestroyRef);
1614
private _injector = inject(Injector);
1715
private _store = inject(Store);
1816
private _internalStateOperations = inject(InternalStateOperations);
@@ -62,12 +60,18 @@ export class LifecycleStateManager {
6260
this._invokeInitOnStates(results.states);
6361

6462
const options = { injector: this._injector };
65-
toObservable(this._appBootstrapState, options)
66-
.pipe(takeUntilDestroyed(this._destroyRef))
67-
.subscribe(appBootstrapped => {
68-
if (!appBootstrapped) return;
69-
this._invokeBootstrapOnStates(results.states);
70-
});
63+
// The effect is destroyed as soon as it fires so that nothing is left
64+
// registered against the root injector's `DestroyRef`. Keeping a live
65+
// effect/subscription around until root injector destruction (e.g. via
66+
// `toObservable` + `takeUntilDestroyed`) races with other root-scoped
67+
// teardown (such as `ɵStateStream` completing itself), which can throw
68+
// `NG0205`/`ObjectUnsubscribedError` during fast TestBed teardown.
69+
const ref = effect(() => {
70+
const appBootstrapped = this._appBootstrapState();
71+
if (!appBootstrapped) return;
72+
ref.destroy();
73+
this._invokeBootstrapOnStates(results.states);
74+
}, options);
7175
});
7276
}
7377

packages/store/tests/state.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
StateContext,
2323
Store
2424
} from '@ngxs/store';
25-
import { ɵMETA_KEY } from '@ngxs/store/internals';
25+
import { ɵMETA_KEY, ɵNGXS_APP_BOOTSTRAP_STATE } from '@ngxs/store/internals';
2626

2727
import { NgxsAfterBootstrap } from '../src/symbols';
2828
import { simplePatch } from '../src/internal/state-operators';
@@ -359,6 +359,45 @@ describe('State', () => {
359359
LifecycleHooks.NgxsAfterBootstrap
360360
]);
361361
});
362+
363+
it('should invoke "ngxsAfterBootstrap" only once, even if the bootstrap signal flips again later', () => {
364+
// This guards against reintroducing `toObservable(...).pipe(takeUntilDestroyed(...))`
365+
// for the bootstrap-completion signal (see #2479): that pattern keeps the
366+
// internal `effect`/subscription alive until the root injector is destroyed,
367+
// which both invokes `ngxsAfterBootstrap` on every subsequent truthy emission
368+
// and races with other root-scoped teardown (e.g. `ɵStateStream` completing
369+
// itself), producing `NG0205`/`ObjectUnsubscribedError` during fast TestBed
370+
// teardown. The fix keeps a self-destroying `effect` that detaches itself
371+
// the moment it fires, so it can only ever run `ngxsAfterBootstrap` once.
372+
@State({
373+
name: 'foo'
374+
})
375+
@Injectable()
376+
class FooState implements NgxsAfterBootstrap {
377+
public ngxsAfterBootstrap(): void {
378+
hooks.push(LifecycleHooks.NgxsAfterBootstrap);
379+
}
380+
}
381+
382+
TestBed.configureTestingModule({
383+
imports: [NgxsModule.forRoot([FooState])]
384+
});
385+
386+
TestBed.inject(Store);
387+
388+
const appBootstrapState = TestBed.inject(ɵNGXS_APP_BOOTSTRAP_STATE);
389+
390+
appBootstrapState.set(true);
391+
TestBed.flushEffects();
392+
393+
appBootstrapState.set(false);
394+
TestBed.flushEffects();
395+
396+
appBootstrapState.set(true);
397+
TestBed.flushEffects();
398+
399+
expect(hooks).toEqual([LifecycleHooks.NgxsAfterBootstrap]);
400+
});
362401
});
363402

364403
describe('simple patch', () => {

0 commit comments

Comments
 (0)