Skip to content

Commit 7341cff

Browse files
committed
feat(store): add registerNgxsPlugin function for dynamic plugin registration
1 parent f3cf7bf commit 7341cff

File tree

5 files changed

+220
-1
lines changed

5 files changed

+220
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ $ npm install @ngxs/store@dev
66

77
### To become next version
88

9-
...
9+
- Feature(store): add `registerNgxsPlugin` function for dynamic plugin registration [#2396](https://github.com/ngxs/store/pull/2396)
1010

1111
### 21.0.0 2025-12-17
1212

packages/store/src/plugin_api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ export {
99
type NgxsPluginFn,
1010
type NgxsNextPluginFn
1111
} from '@ngxs/store/plugins';
12+
13+
export { registerNgxsPlugin } from './register-plugin';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
assertInInjectionContext,
3+
createEnvironmentInjector,
4+
DestroyRef,
5+
EnvironmentInjector,
6+
inject,
7+
InjectionToken,
8+
type Type
9+
} from '@angular/core';
10+
import type { NgxsPlugin, NgxsPluginFn } from '@ngxs/store/plugins';
11+
12+
import { PluginManager } from './plugin-manager';
13+
import { withNgxsPlugin } from './standalone-features/plugin';
14+
15+
const REGISTERED_PLUGINS = /* @__PURE__ */ new InjectionToken('', {
16+
factory: () => {
17+
const plugins = new Set();
18+
inject(DestroyRef).onDestroy(() => plugins.clear());
19+
return plugins;
20+
}
21+
});
22+
23+
/**
24+
* Dynamically registers an NGXS plugin in the current injection context.
25+
*
26+
* This function allows you to register NGXS plugins at runtime, creating an isolated
27+
* environment injector for the plugin. The plugin is automatically cleaned up when
28+
* the injection context is destroyed. In development mode, the function validates
29+
* that the same plugin is not registered multiple times.
30+
*
31+
* @param plugin - The NGXS plugin to register. Can be either a class type implementing
32+
* `NgxsPlugin` or a plugin function (`NgxsPluginFn`).
33+
*
34+
* @throws {Error} Throws an error if called outside of an injection context.
35+
* @throws {Error} In development mode, throws an error if the plugin has already been registered.
36+
*
37+
* @remarks
38+
* - Must be called within an injection context (e.g., constructor, field initializer, or `runInInjectionContext`).
39+
* - The created environment injector is automatically destroyed when the parent context is destroyed.
40+
* - Duplicate plugin registration is only checked in development mode for performance reasons.
41+
*
42+
* @example
43+
* ```ts
44+
* // Register a plugin class
45+
* import { MyThirdPartyIntegrationPlugin } from './plugins/third-party.plugin';
46+
*
47+
* @Component({
48+
* selector: 'app-root',
49+
* template: '...'
50+
* })
51+
* export class AppComponent {
52+
* constructor() {
53+
* registerNgxsPlugin(MyThirdPartyIntegrationPlugin);
54+
* }
55+
* }
56+
* ```
57+
*
58+
* @example
59+
* ```ts
60+
* // Register a plugin function
61+
* import { myThirdPartyIntegrationPluginFn } from './plugins/third-party.plugin';
62+
*
63+
* @Component({
64+
* selector: 'app-feature',
65+
* template: '...'
66+
* })
67+
* export class FeatureComponent {
68+
* constructor() {
69+
* registerNgxsPlugin(myThirdPartyIntegrationPluginFn);
70+
* }
71+
* }
72+
* ```
73+
*
74+
* @example
75+
* ```ts
76+
* // Register conditionally based on environment
77+
* import { MyDevtoolsPlugin } from './plugins/devtools.plugin';
78+
*
79+
* @Component({
80+
* selector: 'app-root',
81+
* template: '...'
82+
* })
83+
* export class AppComponent {
84+
* constructor() {
85+
* if (ngDevMode) {
86+
* registerNgxsPlugin(MyDevtoolsPlugin);
87+
* }
88+
* }
89+
* }
90+
* ```
91+
*/
92+
export function registerNgxsPlugin(plugin: Type<NgxsPlugin> | NgxsPluginFn) {
93+
ngDevMode && assertInInjectionContext(registerNgxsPlugin);
94+
95+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
96+
const registeredPlugins = inject(REGISTERED_PLUGINS);
97+
if (registeredPlugins.has(plugin)) {
98+
throw new Error(
99+
'Plugin has already been registered. Each plugin should only be registered once to avoid unexpected behavior.'
100+
);
101+
}
102+
registeredPlugins.add(plugin);
103+
}
104+
105+
// Create a new environment injector with the plugin configuration.
106+
// This isolates the plugin's dependencies and providers.
107+
const injector = createEnvironmentInjector(
108+
[PluginManager, withNgxsPlugin(plugin)],
109+
inject(EnvironmentInjector)
110+
);
111+
112+
// Ensure the created injector is destroyed when the injection context is destroyed.
113+
// This prevents memory leaks and ensures proper cleanup.
114+
inject(DestroyRef).onDestroy(() => injector.destroy());
115+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
DestroyRef,
3+
inject,
4+
Injectable,
5+
Injector,
6+
runInInjectionContext
7+
} from '@angular/core';
8+
import { NgxsNextPluginFn, NgxsPlugin, registerNgxsPlugin } from '@ngxs/store';
9+
10+
export const recorder: any[] = [];
11+
12+
@Injectable()
13+
class LazyNgxsPlugin implements NgxsPlugin {
14+
constructor() {
15+
inject(DestroyRef).onDestroy(() => recorder.push('LazyNgxsPlugin.destroy()'));
16+
}
17+
18+
handle(state: any, action: any, next: NgxsNextPluginFn) {
19+
recorder.push({ state, action });
20+
return next(state, action);
21+
}
22+
}
23+
24+
export function registerPluginFixture(injector: Injector) {
25+
runInInjectionContext(injector, () => registerNgxsPlugin(LazyNgxsPlugin));
26+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
Component,
3+
inject,
4+
Injectable,
5+
Injector,
6+
PendingTasks,
7+
provideAppInitializer
8+
} from '@angular/core';
9+
import { bootstrapApplication } from '@angular/platform-browser';
10+
import { freshPlatform, skipConsoleLogging } from '@ngxs/store/internals/testing';
11+
import { Action, State, StateContext, Store, provideStore } from '@ngxs/store';
12+
13+
describe('lazyProvider', () => {
14+
class AddCountry {
15+
static readonly type = 'AddCountry';
16+
constructor(readonly country: string) {}
17+
}
18+
19+
@State({
20+
name: 'countries',
21+
defaults: []
22+
})
23+
@Injectable()
24+
class CountriesState {
25+
@Action(AddCountry)
26+
addCountry(ctx: StateContext<string[]>, action: AddCountry) {
27+
ctx.setState(state => [...state, action.country]);
28+
}
29+
}
30+
31+
it(
32+
'should navigate and provide feature store',
33+
freshPlatform(async () => {
34+
// Arrange
35+
@Component({
36+
selector: 'app-root',
37+
template: ''
38+
})
39+
class TestComponent {}
40+
41+
let recorder!: any[];
42+
43+
const appRef = await skipConsoleLogging(() =>
44+
bootstrapApplication(TestComponent, {
45+
providers: [
46+
provideStore([CountriesState], { developmentMode: false }),
47+
provideAppInitializer(() => {
48+
const injector = inject(Injector);
49+
const pendingTasks = inject(PendingTasks);
50+
pendingTasks.run(() =>
51+
import('./fixtures/register-plugin-fixture').then(m => {
52+
m.registerPluginFixture(injector);
53+
recorder = m.recorder;
54+
})
55+
);
56+
})
57+
]
58+
})
59+
);
60+
await appRef.whenStable();
61+
62+
const store = appRef.injector.get(Store);
63+
64+
// Act
65+
const action = new AddCountry('USA');
66+
store.dispatch(action);
67+
appRef.destroy();
68+
69+
// Assert
70+
expect(recorder).toEqual([
71+
{ action, state: { countries: [] } },
72+
'LazyNgxsPlugin.destroy()'
73+
]);
74+
})
75+
);
76+
});

0 commit comments

Comments
 (0)