diff --git a/CHANGELOG.md b/CHANGELOG.md index 666643fd2..21bc4f2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ $ npm install @ngxs/store@dev ### To become next version -... +- Feature(store): add `registerNgxsPlugin` function for dynamic plugin registration [#2396](https://github.com/ngxs/store/pull/2396) ### 21.0.0 2025-12-17 diff --git a/packages/store/src/plugin_api.ts b/packages/store/src/plugin_api.ts index 22be08baf..5ae6b5e42 100644 --- a/packages/store/src/plugin_api.ts +++ b/packages/store/src/plugin_api.ts @@ -9,3 +9,5 @@ export { type NgxsPluginFn, type NgxsNextPluginFn } from '@ngxs/store/plugins'; + +export { registerNgxsPlugin } from './register-plugin'; diff --git a/packages/store/src/register-plugin.ts b/packages/store/src/register-plugin.ts new file mode 100644 index 000000000..cf1d2ab55 --- /dev/null +++ b/packages/store/src/register-plugin.ts @@ -0,0 +1,115 @@ +import { + assertInInjectionContext, + createEnvironmentInjector, + DestroyRef, + EnvironmentInjector, + inject, + InjectionToken, + type Type +} from '@angular/core'; +import type { NgxsPlugin, NgxsPluginFn } from '@ngxs/store/plugins'; + +import { PluginManager } from './plugin-manager'; +import { withNgxsPlugin } from './standalone-features/plugin'; + +const REGISTERED_PLUGINS = /* @__PURE__ */ new InjectionToken('', { + factory: () => { + const plugins = new Set(); + inject(DestroyRef).onDestroy(() => plugins.clear()); + return plugins; + } +}); + +/** + * Dynamically registers an NGXS plugin in the current injection context. + * + * This function allows you to register NGXS plugins at runtime, creating an isolated + * environment injector for the plugin. The plugin is automatically cleaned up when + * the injection context is destroyed. In development mode, the function validates + * that the same plugin is not registered multiple times. + * + * @param plugin - The NGXS plugin to register. Can be either a class type implementing + * `NgxsPlugin` or a plugin function (`NgxsPluginFn`). + * + * @throws {Error} Throws an error if called outside of an injection context. + * @throws {Error} In development mode, throws an error if the plugin has already been registered. + * + * @remarks + * - Must be called within an injection context (e.g., constructor, field initializer, or `runInInjectionContext`). + * - The created environment injector is automatically destroyed when the parent context is destroyed. + * - Duplicate plugin registration is only checked in development mode for performance reasons. + * + * @example + * ```ts + * // Register a plugin class + * import { MyThirdPartyIntegrationPlugin } from './plugins/third-party.plugin'; + * + * @Component({ + * selector: 'app-root', + * template: '...' + * }) + * export class AppComponent { + * constructor() { + * registerNgxsPlugin(MyThirdPartyIntegrationPlugin); + * } + * } + * ``` + * + * @example + * ```ts + * // Register a plugin function + * import { myThirdPartyIntegrationPluginFn } from './plugins/third-party.plugin'; + * + * @Component({ + * selector: 'app-feature', + * template: '...' + * }) + * export class FeatureComponent { + * constructor() { + * registerNgxsPlugin(myThirdPartyIntegrationPluginFn); + * } + * } + * ``` + * + * @example + * ```ts + * // Register conditionally based on environment + * import { MyDevtoolsPlugin } from './plugins/devtools.plugin'; + * + * @Component({ + * selector: 'app-root', + * template: '...' + * }) + * export class AppComponent { + * constructor() { + * if (ngDevMode) { + * registerNgxsPlugin(MyDevtoolsPlugin); + * } + * } + * } + * ``` + */ +export function registerNgxsPlugin(plugin: Type | NgxsPluginFn) { + ngDevMode && assertInInjectionContext(registerNgxsPlugin); + + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + const registeredPlugins = inject(REGISTERED_PLUGINS); + if (registeredPlugins.has(plugin)) { + throw new Error( + 'Plugin has already been registered. Each plugin should only be registered once to avoid unexpected behavior.' + ); + } + registeredPlugins.add(plugin); + } + + // Create a new environment injector with the plugin configuration. + // This isolates the plugin's dependencies and providers. + const injector = createEnvironmentInjector( + [PluginManager, withNgxsPlugin(plugin)], + inject(EnvironmentInjector) + ); + + // Ensure the created injector is destroyed when the injection context is destroyed. + // This prevents memory leaks and ensures proper cleanup. + inject(DestroyRef).onDestroy(() => injector.destroy()); +} diff --git a/packages/store/tests/utils/fixtures/register-plugin-fixture.ts b/packages/store/tests/utils/fixtures/register-plugin-fixture.ts new file mode 100644 index 000000000..092176deb --- /dev/null +++ b/packages/store/tests/utils/fixtures/register-plugin-fixture.ts @@ -0,0 +1,26 @@ +import { + DestroyRef, + inject, + Injectable, + Injector, + runInInjectionContext +} from '@angular/core'; +import { NgxsNextPluginFn, NgxsPlugin, registerNgxsPlugin } from '@ngxs/store'; + +export const recorder: any[] = []; + +@Injectable() +class LazyNgxsPlugin implements NgxsPlugin { + constructor() { + inject(DestroyRef).onDestroy(() => recorder.push('LazyNgxsPlugin.destroy()')); + } + + handle(state: any, action: any, next: NgxsNextPluginFn) { + recorder.push({ state, action }); + return next(state, action); + } +} + +export function registerPluginFixture(injector: Injector) { + runInInjectionContext(injector, () => registerNgxsPlugin(LazyNgxsPlugin)); +} diff --git a/packages/store/tests/utils/register-plugin.spec.ts b/packages/store/tests/utils/register-plugin.spec.ts new file mode 100644 index 000000000..4bf112c33 --- /dev/null +++ b/packages/store/tests/utils/register-plugin.spec.ts @@ -0,0 +1,76 @@ +import { + Component, + inject, + Injectable, + Injector, + PendingTasks, + provideAppInitializer +} from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { freshPlatform, skipConsoleLogging } from '@ngxs/store/internals/testing'; +import { Action, State, StateContext, Store, provideStore } from '@ngxs/store'; + +describe('lazyProvider', () => { + class AddCountry { + static readonly type = 'AddCountry'; + constructor(readonly country: string) {} + } + + @State({ + name: 'countries', + defaults: [] + }) + @Injectable() + class CountriesState { + @Action(AddCountry) + addCountry(ctx: StateContext, action: AddCountry) { + ctx.setState(state => [...state, action.country]); + } + } + + it( + 'should navigate and provide feature store', + freshPlatform(async () => { + // Arrange + @Component({ + selector: 'app-root', + template: '' + }) + class TestComponent {} + + let recorder!: any[]; + + const appRef = await skipConsoleLogging(() => + bootstrapApplication(TestComponent, { + providers: [ + provideStore([CountriesState], { developmentMode: false }), + provideAppInitializer(() => { + const injector = inject(Injector); + const pendingTasks = inject(PendingTasks); + pendingTasks.run(() => + import('./fixtures/register-plugin-fixture').then(m => { + m.registerPluginFixture(injector); + recorder = m.recorder; + }) + ); + }) + ] + }) + ); + await appRef.whenStable(); + + const store = appRef.injector.get(Store); + + // Act + const action = new AddCountry('USA'); + store.dispatch(action); + appRef.destroy(); + + // Assert + expect(recorder).toEqual([ + { action, state: { countries: [] } }, + 'LazyNgxsPlugin.destroy()' + ]); + }) + ); +});