Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/store/src/plugin_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export {
type NgxsPluginFn,
type NgxsNextPluginFn
} from '@ngxs/store/plugins';

export { registerNgxsPlugin } from './register-plugin';
115 changes: 115 additions & 0 deletions packages/store/src/register-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<NgxsPlugin> | 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());
}
26 changes: 26 additions & 0 deletions packages/store/tests/utils/fixtures/register-plugin-fixture.ts
Original file line number Diff line number Diff line change
@@ -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));
}
76 changes: 76 additions & 0 deletions packages/store/tests/utils/register-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>, 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()'
]);
})
);
});
Loading