From e5f7f57edb7de2a99c5a115d625fa9b8504dad3a Mon Sep 17 00:00:00 2001 From: arturovt Date: Wed, 2 Sep 2026 18:29:30 +0300 Subject: [PATCH] fix(store): stop connecting the @Select decorator automatically `SelectFactory` writes the request's `Store` into a module-level static so the `@Select` getter can reach it without DI. On the server that static outlives the request, retaining the whole state graph for the life of the process. `rootStoreInitializer()` forced `SelectFactory` to exist on every `provideStore()` / `NgxsModule.forRoot()`, whether or not `@Select` was used. It is now opt-in, so apps that do not use `@Select` (the deprecated path) no longer pay for it. BREAKING CHANGES: Apps using the deprecated `@Select` decorator must now connect it explicitly, otherwise accessing a `@Select` property throws. Standalone: ```ts provideStore([CountriesState], withNgxsSelectDecoratorSupport()); ``` NgModule: ```ts @NgModule({ imports: [ NgxsModule.forRoot([CountriesState]), NgxsSelectDecoratorSupportModule.forRoot() ] }) export class AppModule {} ``` Prefer migrating to `store.select()`, the functional `select()`, or `store.selectSignal()`: https://ngxs.io/deprecations/select-decorator-deprecation --- CHANGELOG.md | 1 + docs/concepts/select/select-decorator.md | 2 +- .../select-decorator-deprecation.md | 23 +++++++- .../select/select-decorator.module.ts | 55 +++++++++++++++++++ .../store/src/decorators/select/select.ts | 13 ++++- .../store/src/decorators/select/symbols.ts | 8 ++- packages/store/src/public_api.ts | 4 ++ .../src/standalone-features/initializers.ts | 2 - .../store/tests/release-resources.spec.ts | 8 ++- packages/store/tests/select.spec.ts | 31 +++++++---- 10 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 packages/store/src/decorators/select/select-decorator.module.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0de99e0..4da4c359d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ $ npm install @ngxs/store@dev ### To become next version +- Fix(store): The deprecated `@Select` decorator no longer works on its own (it held the request's `Store` in a module-level static, leaking on the server). Apps still using `@Select` must add `withNgxsSelectDecoratorSupport()` to `provideStore(...)`, or `NgxsSelectDecoratorSupportModule.forRoot()` next to `NgxsModule.forRoot(...)` - or move to `store.select()` / `select()` / `store.selectSignal()` [#XXXX](https://github.com/ngxs/store/pull/XXXX) - Feature(store): Warn when `setState`/`patchState` produce a new reference with an identical value [#2465](https://github.com/ngxs/store/pull/2465) - Feature(store): Warn on duplicate action types via `warnOnDuplicateActionTypes` [#2466](https://github.com/ngxs/store/pull/2466) - Feature(store): Add `ignoreUncompleted` action option [#2470](https://github.com/ngxs/store/pull/2470) diff --git a/docs/concepts/select/select-decorator.md b/docs/concepts/select/select-decorator.md index 126992a1e..414ca6d69 100644 --- a/docs/concepts/select/select-decorator.md +++ b/docs/concepts/select/select-decorator.md @@ -3,7 +3,7 @@ {% hint style="danger" %} **DEPRECATED** -[Find out why we are deprecating the select decorator](../../deprecations/select-decorator-deprecation.md) +`@Select` is no longer enabled by default - it leaks on the server. Add `withNgxsSelectDecoratorSupport()` to `provideStore(...)` (or `NgxsSelectDecoratorSupportModule.forRoot()` next to `NgxsModule.forRoot(...)`) to keep using it, and see [why we are deprecating the select decorator](../../deprecations/select-decorator-deprecation.md). {% endhint %} You can select slices of data from the store using the `@Select` decorator. It has a few different ways to get your data out, whether passing the state class, a function, a different state class or a memoized selector. diff --git a/docs/deprecations/select-decorator-deprecation.md b/docs/deprecations/select-decorator-deprecation.md index 74323f39a..9ec0ab167 100644 --- a/docs/deprecations/select-decorator-deprecation.md +++ b/docs/deprecations/select-decorator-deprecation.md @@ -2,7 +2,28 @@ The `@Select` decorator is slated for removal in the future due to its inherent risks. It lacks integration with Angular's dependency injection system, making it prone to failures in scenarios with multiple simultaneous applications, such as server-side rendering and microfrontend setups. -Previously, the decorator stored the `Store` instance in a static variable, which could be overwritten by subsequent bootstrapped or removed applications. If a second application was created and destroyed before the first one, it could nullify the static variable, rendering the store inaccessible to the first application. +The decorator stores the `Store` instance in a static variable, which could be overwritten by subsequent bootstrapped or removed applications. If a second application was created and destroyed before the first one, it could nullify the static variable, rendering the store inaccessible to the first application. On the server, that same static keeps the request's `Store` (and its whole state graph) alive for the life of the process, so an app that doesn't use `@Select` still pays for it with a memory leak. + +## `@Select` is no longer enabled by default + +Because of the leak above, NGXS no longer creates the machinery `@Select` relies on unless you ask for it. If you can't migrate right away, opt in: + +```ts +// standalone +provideStore([UsersState], withNgxsSelectDecoratorSupport()); +``` + +```ts +// NgModule +@NgModule({ + imports: [NgxsModule.forRoot([UsersState]), NgxsSelectDecoratorSupportModule.forRoot()] +}) +export class AppModule {} +``` + +Without it, reading a `@Select` property throws. Migrating off `@Select` is still the recommended path. + +## Migrating away from `@Select` Every `@Select` usage should be replaced with the following: diff --git a/packages/store/src/decorators/select/select-decorator.module.ts b/packages/store/src/decorators/select/select-decorator.module.ts new file mode 100644 index 000000000..d67eb512b --- /dev/null +++ b/packages/store/src/decorators/select/select-decorator.module.ts @@ -0,0 +1,55 @@ +import { + EnvironmentProviders, + ModuleWithProviders, + NgModule, + inject, + provideEnvironmentInitializer +} from '@angular/core'; + +import { SelectFactory } from './select-factory'; + +/** + * Turns on the deprecated `@Select` decorator. + * + * `@Select` grabs the store from a static on `SelectFactory` - it has no + * injection context of its own. On the server that static hangs around between + * requests and pins the whole state tree, so NGXS doesn't create it for you + * anymore. Add this only if you still use `@Select`. + * + * ```ts + * provideStore([CountriesState], withNgxsSelectDecoratorSupport()); + * ``` + * + * @deprecated `@Select` is deprecated - move to `store.select()`, the functional + * `select()`, or `store.selectSignal()`: + * https://ngxs.io/deprecations/select-decorator-deprecation + */ +export function withNgxsSelectDecoratorSupport(): EnvironmentProviders { + return provideEnvironmentInitializer(() => inject(SelectFactory)); +} + +/** + * `NgModule` version of `withNgxsSelectDecoratorSupport()`, for apps still on + * `NgxsModule.forRoot()`. + * + * ```ts + * @NgModule({ + * imports: [ + * NgxsModule.forRoot([CountriesState]), + * NgxsSelectDecoratorSupportModule.forRoot() + * ] + * }) + * export class AppModule {} + * ``` + * + * @deprecated See `withNgxsSelectDecoratorSupport()`. + */ +@NgModule() +export class NgxsSelectDecoratorSupportModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: NgxsSelectDecoratorSupportModule, + providers: [withNgxsSelectDecoratorSupport()] + }; + } +} diff --git a/packages/store/src/decorators/select/select.ts b/packages/store/src/decorators/select/select.ts index 1b3096a7f..06bec0d0d 100644 --- a/packages/store/src/decorators/select/select.ts +++ b/packages/store/src/decorators/select/select.ts @@ -3,8 +3,17 @@ import { createSelectObservable, createSelectorFn, PropertyType } from './symbol /** * Decorator for selecting a slice of state from the store. * - * @deprecated - * Read the deprecation notice at this link: https://ngxs.io/deprecations/select-decorator-deprecation. + * It doesn't work on its own anymore - opt in with + * `withNgxsSelectDecoratorSupport()` in `provideStore(...)`, or + * `NgxsSelectDecoratorSupportModule.forRoot()` next to `NgxsModule.forRoot(...)`: + * + * ```ts + * provideStore([CountriesState], withNgxsSelectDecoratorSupport()); + * ``` + * + * @deprecated Use `store.select()`, the functional `select()`, or + * `store.selectSignal()` instead: + * https://ngxs.io/deprecations/select-decorator-deprecation */ export function Select(rawSelector?: T, ...paths: string[]): PropertyDecorator { return function (target, key): void { diff --git a/packages/store/src/decorators/select/symbols.ts b/packages/store/src/decorators/select/symbols.ts index 54428e5fb..2a46014c8 100644 --- a/packages/store/src/decorators/select/symbols.ts +++ b/packages/store/src/decorators/select/symbols.ts @@ -23,7 +23,13 @@ export function propGetter(paths: string[], config: NgxsConfig) { } function throwSelectFactoryNotConnectedError(): never { - throw new Error('You have forgotten to import the NGXS module!'); + throw new Error( + "`@Select` is deprecated and doesn't work on its own anymore. Add " + + '`withNgxsSelectDecoratorSupport()` to `provideStore(...)`, or ' + + '`NgxsSelectDecoratorSupportModule.forRoot()` next to `NgxsModule.forRoot(...)`. ' + + 'Better to move to `store.select()`, `select()` or `store.selectSignal()`: ' + + 'https://ngxs.io/deprecations/select-decorator-deprecation' + ); } const DOLLAR_CHAR_CODE = 36; diff --git a/packages/store/src/public_api.ts b/packages/store/src/public_api.ts index 987820efc..ec26d478f 100644 --- a/packages/store/src/public_api.ts +++ b/packages/store/src/public_api.ts @@ -3,6 +3,10 @@ export { Action } from './decorators/action'; export { Store } from './store'; export { State } from './decorators/state'; export { Select } from './decorators/select/select'; +export { + withNgxsSelectDecoratorSupport, + NgxsSelectDecoratorSupportModule +} from './decorators/select/select-decorator.module'; export { SelectorOptions } from './decorators/selector-options'; export { Actions, type ActionContext, ActionStatus } from './actions-stream'; diff --git a/packages/store/src/standalone-features/initializers.ts b/packages/store/src/standalone-features/initializers.ts index 342033cba..d46f87e90 100644 --- a/packages/store/src/standalone-features/initializers.ts +++ b/packages/store/src/standalone-features/initializers.ts @@ -13,7 +13,6 @@ import { FEATURE_STATE_TOKEN, ROOT_STATE_TOKEN } from '../symbols'; import { StateFactory } from '../internal/state-factory'; import { StatesAndDefaults } from '../internal/internals'; import { assertRootStoreNotInitialized } from './root-guard'; -import { SelectFactory } from '../decorators/select/select-factory'; import { InternalStateOperations } from '../internal/state-operations'; import { LifecycleStateManager } from '../internal/lifecycle-state-manager'; import { installOnUnhandhedErrorHandler } from '../internal/unhandled-rxjs-error-callback'; @@ -41,7 +40,6 @@ export function rootStoreInitializer(): void { const internalStateOperations = inject(InternalStateOperations); inject(Store); - inject(SelectFactory); const states = inject(ROOT_STATE_TOKEN, { optional: true }) || []; const lifecycleStateManager = inject(LifecycleStateManager); diff --git a/packages/store/tests/release-resources.spec.ts b/packages/store/tests/release-resources.spec.ts index fa0f7bdb8..5984323a0 100644 --- a/packages/store/tests/release-resources.spec.ts +++ b/packages/store/tests/release-resources.spec.ts @@ -1,7 +1,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule, ErrorHandler, DoBootstrap } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { NgxsModule, Store } from '@ngxs/store'; +import { NgxsModule, NgxsSelectDecoratorSupportModule, Store } from '@ngxs/store'; import { freshPlatform } from '@ngxs/store/internals/testing'; import { NoopErrorHandler } from './helpers/utils'; @@ -13,7 +13,11 @@ describe('Release NGXS resources', () => { freshPlatform(async () => { // Arrange @NgModule({ - imports: [BrowserModule, NgxsModule.forRoot([])], + imports: [ + BrowserModule, + NgxsModule.forRoot([]), + NgxsSelectDecoratorSupportModule.forRoot() + ], providers: [{ provide: ErrorHandler, useClass: NoopErrorHandler }] }) class TestModule implements DoBootstrap { diff --git a/packages/store/tests/select.spec.ts b/packages/store/tests/select.spec.ts index 494c28016..101aa51f2 100644 --- a/packages/store/tests/select.spec.ts +++ b/packages/store/tests/select.spec.ts @@ -3,7 +3,16 @@ import { combineLatest, Observable, Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; import { Component, Injectable, NgModule, inject } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; -import { Store, NgxsModule, State, Action, Selector, Select, StateContext } from '@ngxs/store'; +import { + Store, + NgxsModule, + NgxsSelectDecoratorSupportModule, + State, + Action, + Selector, + Select, + StateContext +} from '@ngxs/store'; import { skipConsoleLogging, freshPlatform } from '@ngxs/store/internals/testing'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; @@ -68,7 +77,7 @@ describe('Select', () => { const states = [MySubState, MySubSubState, MyState]; - it('should throw an exception when the user has forgotten to import the NGXS module', () => { + it('should throw an exception when `@Select` support has not been connected', () => { // Arrange let message: string | null = null; @@ -84,7 +93,7 @@ describe('Select', () => { } // Assert - expect(message).toEqual('You have forgotten to import the NGXS module!'); + expect(message).toContain('withNgxsSelectDecoratorSupport()'); }); it('should throw an exception when the component class is frozen', () => { @@ -111,7 +120,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [MySelectComponent] }); @@ -143,7 +152,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [SelectComponent] }); @@ -170,7 +179,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StringSelectComponent] }); @@ -204,7 +213,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StoreSelectComponent] }); @@ -236,7 +245,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StoreSelectComponent] }); @@ -267,7 +276,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StoreSelectComponent] }); @@ -305,7 +314,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StoreSelectComponent] }); @@ -332,7 +341,7 @@ describe('Select', () => { } TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot(states)], + imports: [NgxsModule.forRoot(states), NgxsSelectDecoratorSupportModule.forRoot()], declarations: [StoreSelectComponent] });