@real-router/persistent-params-plugin 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -1,300 +1,137 @@
1
- // packages/persistent-params-plugin/modules/plugin.ts
2
-
3
- import { getPluginApi } from "@real-router/core";
4
-
5
- import { PLUGIN_MARKER } from "./constants";
6
- import {
7
- buildQueryString,
8
- extractOwnParams,
9
- isValidParamsConfig,
10
- mergeParams,
11
- parseQueryString,
12
- validateParamValue,
13
- } from "./utils";
14
-
15
- import type { PersistentParamsConfig } from "./types";
16
- import type { Params, PluginFactory, Plugin } from "@real-router/core";
17
-
18
- /**
19
- * Factory for the persistent parameters' plugin.
20
- *
21
- * This plugin allows you to specify certain route parameters to be persisted across
22
- * all navigation transitions. Persisted parameters are automatically merged into
23
- * route parameters when building paths or states.
24
- *
25
- * Key features:
26
- * - Automatic persistence of query parameters across navigations
27
- * - Support for default values
28
- * - Type-safe (only primitives: string, number, boolean)
29
- * - Immutable internal state
30
- * - Protection against prototype pollution
31
- * - Full teardown support (can be safely unsubscribed)
32
- *
33
- * If a persisted parameter is explicitly set to `undefined` during navigation,
34
- * it will be removed from the persisted state and omitted from subsequent URLs.
35
- *
36
- * The plugin also adjusts the router's root path to include query parameters for
37
- * all persistent params, ensuring correct URL construction.
38
- *
39
- * @param params - Either an array of parameter names (strings) to persist,
40
- * or an object mapping parameter names to initial values.
41
- * If an array, initial values will be `undefined`.
42
- *
43
- * @returns A PluginFactory that creates the persistent params plugin instance.
44
- *
45
- * @example
46
- * // Persist parameters without default values
47
- * router.usePlugin(persistentParamsPlugin(['mode', 'lang']));
48
- *
49
- * @example
50
- * // Persist parameters with default values
51
- * router.usePlugin(persistentParamsPlugin({ mode: 'dev', lang: 'en' }));
52
- *
53
- * @example
54
- * // Removing a persisted parameter
55
- * router.navigate('route', { mode: undefined }); // mode will be removed
56
- *
57
- * @example
58
- * // Unsubscribing (full cleanup)
59
- * const unsubscribe = router.usePlugin(persistentParamsPlugin(['mode']));
60
- * unsubscribe(); // Restores original router state
61
- *
62
- * @throws {TypeError} If params is not a valid array of strings or object with primitives
63
- * @throws {Error} If plugin is already initialized on this router instance
64
- */
65
- export function persistentParamsPluginFactory(
66
- params: PersistentParamsConfig = {},
67
- ): PluginFactory {
68
- // Validate input configuration
69
- if (!isValidParamsConfig(params)) {
70
- let actualType: string;
71
-
72
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
73
- if (params === null) {
74
- actualType = "null";
75
- } else if (Array.isArray(params)) {
76
- actualType = "array with invalid items";
77
- } else {
78
- actualType = typeof params;
79
- }
80
-
81
- throw new TypeError(
82
- `[@real-router/persistent-params-plugin] Invalid params configuration. ` +
83
- `Expected array of non-empty strings or object with primitive values, got ${actualType}.`,
84
- );
85
- }
86
-
87
- // Empty configuration - valid but does nothing
88
- if (Array.isArray(params) && params.length === 0) {
89
- return () => ({});
90
- }
91
-
92
- if (!Array.isArray(params) && Object.keys(params).length === 0) {
93
- return () => ({});
1
+ // packages/persistent-params-plugin/src/plugin.ts
2
+
3
+ import { extractOwnParams, mergeParams } from "./param-utils";
4
+ import { validateParamValue } from "./validation";
5
+
6
+ import type { Params, PluginApi, State, Plugin } from "@real-router/core";
7
+
8
+ export class PersistentParamsPlugin {
9
+ readonly #api: PluginApi;
10
+ readonly #paramNamesSet: Set<string>;
11
+ readonly #originalRootPath: string;
12
+
13
+ #persistentParams: Readonly<Params>;
14
+ #removeBuildPathInterceptor: (() => void) | undefined;
15
+ #removeForwardStateInterceptor: (() => void) | undefined;
16
+
17
+ constructor(
18
+ api: PluginApi,
19
+ persistentParams: Readonly<Params>,
20
+ paramNamesSet: Set<string>,
21
+ originalRootPath: string,
22
+ ) {
23
+ this.#api = api;
24
+ this.#persistentParams = persistentParams;
25
+ this.#paramNamesSet = paramNamesSet;
26
+ this.#originalRootPath = originalRootPath;
94
27
  }
95
28
 
96
- return (router): Plugin => {
97
- // Check if plugin is already initialized on this router
98
- if (PLUGIN_MARKER in router) {
99
- throw new Error(
100
- `[@real-router/persistent-params-plugin] Plugin already initialized on this router. ` +
101
- `To reconfigure, first unsubscribe the existing plugin using the returned unsubscribe function.`,
102
- );
103
- }
104
-
105
- // Mark router as initialized
106
- (router as unknown as Record<symbol, boolean>)[PLUGIN_MARKER] = true;
107
-
108
- // Initialize frozen persistent parameters
109
- let persistentParams: Readonly<Params>;
110
-
111
- if (Array.isArray(params)) {
112
- const initial: Params = {};
113
-
114
- for (const param of params) {
115
- initial[param] = undefined;
116
- }
117
-
118
- persistentParams = Object.freeze(initial);
119
- } else {
120
- persistentParams = Object.freeze({ ...params });
121
- }
122
-
123
- // Track parameter names
124
- const paramNamesSet = new Set<string>(
125
- Array.isArray(params) ? [...params] : Object.keys(params),
126
- );
127
-
128
- const api = getPluginApi(router);
129
-
130
- // Store original router methods for restoration
131
- const originalBuildPath = router.buildPath.bind(router);
132
- const originalForwardState = api.getForwardState();
133
- const originalRootPath = api.getRootPath();
134
-
135
- // Update router root path to include query parameters for persistent params
29
+ getPlugin(): Plugin {
136
30
  try {
137
- const { basePath, queryString } = parseQueryString(originalRootPath);
138
- const newQueryString = buildQueryString(queryString, [...paramNamesSet]);
31
+ const queryString = [...this.#paramNamesSet].join("&");
139
32
 
140
- api.setRootPath(`${basePath}?${newQueryString}`);
33
+ this.#api.setRootPath(`${this.#originalRootPath}?${queryString}`);
141
34
  } /* v8 ignore start -- @preserve: defensive error wrapping for setRootPath failure */ catch (error) {
142
- delete (router as unknown as Record<symbol, boolean>)[PLUGIN_MARKER];
143
-
144
35
  throw new Error(
145
36
  `[@real-router/persistent-params-plugin] Failed to update root path: ${error instanceof Error ? error.message : String(error)}`,
146
37
  { cause: error },
147
38
  );
148
39
  } /* v8 ignore stop */
149
40
 
150
- /**
151
- * Merges persistent parameters with current navigation parameters.
152
- * Validates all parameter values before merging.
153
- *
154
- * @param additionalParams - Parameters passed during navigation
155
- * @returns Merged parameters object
156
- * @throws {TypeError} If any parameter value is invalid (not a primitive)
157
- */
158
-
159
- function withPersistentParams(additionalParams: Params): Params {
160
- // Extract safe params (prevent prototype pollution)
161
- const safeParams = extractOwnParams(additionalParams);
162
-
163
- // Validate and collect parameters to remove in a single pass
164
- const paramsToRemove: string[] = [];
165
-
166
- for (const key of Object.keys(safeParams)) {
167
- const value = safeParams[key];
168
-
169
- // If undefined and tracked, mark for removal (skip validation)
170
- if (value === undefined && paramNamesSet.has(key)) {
171
- paramsToRemove.push(key);
172
- } else {
173
- // Validate all other parameters
174
- validateParamValue(key, value);
175
- }
176
- }
177
-
178
- // Process all removals in one batch
179
- if (paramsToRemove.length > 0) {
180
- // Remove from both Set
181
- for (const key of paramsToRemove) {
182
- paramNamesSet.delete(key);
183
- }
184
-
185
- // Update persistentParams once (batch freeze)
186
- const newParams: Params = { ...persistentParams };
187
-
188
- for (const key of paramsToRemove) {
189
- delete newParams[key];
190
- }
191
-
192
- persistentParams = Object.freeze(newParams);
193
- }
194
-
195
- // Merge persistent and current params
196
- return mergeParams(persistentParams, safeParams);
197
- }
198
-
199
- // Override router methods to inject persistent params
200
- // buildPath: needed for direct buildPath() calls (doesn't go through forwardState)
201
- router.buildPath = (routeName, buildPathParams = {}) =>
202
- originalBuildPath(routeName, withPersistentParams(buildPathParams));
41
+ this.#removeBuildPathInterceptor = this.#api.addInterceptor(
42
+ "buildPath",
43
+ (next, route, navParams) =>
44
+ next(route, this.#withPersistentParams(navParams ?? {})),
45
+ );
203
46
 
204
- api.setForwardState(
205
- <P extends Params = Params>(routeName: string, routeParams: P) => {
206
- const result = originalForwardState(routeName, routeParams);
47
+ this.#removeForwardStateInterceptor = this.#api.addInterceptor(
48
+ "forwardState",
49
+ (next, routeName, routeParams) => {
50
+ const result = next(routeName, routeParams);
207
51
 
208
52
  return {
209
53
  ...result,
210
- params: withPersistentParams(result.params) as P,
54
+ params: this.#withPersistentParams(result.params),
211
55
  };
212
56
  },
213
57
  );
214
58
 
215
59
  return {
216
- /**
217
- * Updates persistent parameters after successful transition.
218
- * Only processes parameters that are tracked and have changed.
219
- *
220
- * @param toState - Target state after successful transition
221
- */
222
- onTransitionSuccess(toState) {
223
- try {
224
- // Collect changed parameters and removals
225
- const updates: Params = {};
226
- const removals: string[] = [];
227
- let hasChanges = false;
60
+ onTransitionSuccess: (toState) => {
61
+ this.#onTransitionSuccess(toState);
62
+ },
63
+ teardown: () => {
64
+ this.#teardown();
65
+ },
66
+ };
67
+ }
228
68
 
229
- for (const key of paramNamesSet) {
230
- const value = toState.params[key];
69
+ #withPersistentParams(additionalParams: Params): Params {
70
+ const safeParams = extractOwnParams(additionalParams);
71
+ let newParams: Params | undefined;
231
72
 
232
- // If parameter is not in state params or is undefined, mark for removal
233
- if (!Object.hasOwn(toState.params, key) || value === undefined) {
234
- /* v8 ignore next 6 -- @preserve: defensive removal for states committed via navigateToState bypassing forwardState */
235
- if (
236
- Object.hasOwn(persistentParams, key) &&
237
- persistentParams[key] !== undefined
238
- ) {
239
- removals.push(key);
240
- hasChanges = true;
241
- }
73
+ for (const key of Object.keys(safeParams)) {
74
+ const value = safeParams[key];
242
75
 
243
- continue;
244
- }
76
+ if (value === undefined && this.#paramNamesSet.has(key)) {
77
+ this.#paramNamesSet.delete(key);
78
+ newParams ??= { ...this.#persistentParams };
79
+ delete newParams[key];
80
+ } else {
81
+ validateParamValue(key, value);
82
+ }
83
+ }
245
84
 
246
- // Validate type before storing
247
- validateParamValue(key, value);
85
+ if (newParams) {
86
+ this.#persistentParams = Object.freeze(newParams);
87
+ }
248
88
 
249
- // Only update if value actually changed
250
- if (persistentParams[key] !== value) {
251
- updates[key] = value;
252
- hasChanges = true;
253
- }
254
- }
89
+ return mergeParams(this.#persistentParams, safeParams);
90
+ }
255
91
 
256
- // Create new frozen object only if there were changes
257
- if (hasChanges) {
258
- const newParams: Params = { ...persistentParams, ...updates };
92
+ #onTransitionSuccess(toState: State): void {
93
+ let newParams: Params | undefined;
259
94
 
260
- /* v8 ignore next 3 -- @preserve: removals only populated by defensive navigateToState path above */
261
- for (const key of removals) {
262
- delete newParams[key];
263
- }
95
+ for (const key of this.#paramNamesSet) {
96
+ const value = toState.params[key];
264
97
 
265
- persistentParams = Object.freeze(newParams);
266
- }
267
- } catch (error) {
268
- // Log error but don't break navigation
269
- /* v8 ignore next 5 -- @preserve defensive: validation happens before navigate() */
270
- console.error(
271
- "persistent-params-plugin",
272
- "Error updating persistent params:",
273
- error,
274
- );
98
+ if (!Object.hasOwn(toState.params, key) || value === undefined) {
99
+ /* v8 ignore next 4 -- @preserve: defensive removal for states committed via navigateToState bypassing forwardState */
100
+ if (
101
+ Object.hasOwn(this.#persistentParams, key) &&
102
+ this.#persistentParams[key] !== undefined
103
+ ) {
104
+ newParams ??= { ...this.#persistentParams };
105
+ delete newParams[key];
275
106
  }
276
- },
277
107
 
278
- /**
279
- * Cleanup function to restore original router state.
280
- * Restores all overridden methods and paths.
281
- * Called when plugin is unsubscribed.
282
- */
283
- teardown() {
284
- try {
285
- router.buildPath = originalBuildPath;
286
- api.setForwardState(originalForwardState);
287
- api.setRootPath(originalRootPath);
108
+ continue;
109
+ }
288
110
 
289
- delete (router as unknown as Record<symbol, boolean>)[PLUGIN_MARKER];
290
- } /* v8 ignore start -- @preserve: defensive error logging for teardown failure */ catch (error) {
291
- console.error(
292
- "persistent-params-plugin",
293
- "Error during teardown:",
294
- error,
295
- );
296
- } /* v8 ignore stop */
297
- },
298
- };
299
- };
111
+ validateParamValue(key, value);
112
+
113
+ if (this.#persistentParams[key] !== value) {
114
+ newParams ??= { ...this.#persistentParams };
115
+ newParams[key] = value;
116
+ }
117
+ }
118
+
119
+ if (newParams) {
120
+ this.#persistentParams = Object.freeze(newParams);
121
+ }
122
+ }
123
+
124
+ #teardown(): void {
125
+ try {
126
+ this.#removeBuildPathInterceptor?.();
127
+ this.#removeForwardStateInterceptor?.();
128
+ this.#api.setRootPath(this.#originalRootPath);
129
+ } /* v8 ignore start -- @preserve: defensive error logging for teardown failure */ catch (error) {
130
+ console.error(
131
+ "persistent-params-plugin",
132
+ "Error during teardown:",
133
+ error,
134
+ );
135
+ } /* v8 ignore stop */
136
+ }
300
137
  }
@@ -0,0 +1,128 @@
1
+ // packages/persistent-params-plugin/src/validation.ts
2
+
3
+ import { isPrimitiveValue } from "type-guards";
4
+
5
+ import type { PersistentParamsConfig } from "./types";
6
+
7
+ const INVALID_PARAM_KEY_REGEX = /[\s#%&/=?\\]/;
8
+ const INVALID_CHARS_MESSAGE = String.raw`Cannot contain: = & ? # % / \ or whitespace`;
9
+
10
+ export function validateParamKey(key: string): void {
11
+ if (INVALID_PARAM_KEY_REGEX.test(key)) {
12
+ throw new TypeError(
13
+ `[@real-router/persistent-params-plugin] Invalid parameter name "${key}". ${INVALID_CHARS_MESSAGE}`,
14
+ );
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Validates params configuration structure and values.
20
+ * Ensures all parameter names are non-empty strings and all default values are primitives.
21
+ *
22
+ * @param config - Configuration to validate
23
+ * @returns true if configuration is valid
24
+ */
25
+ export function isValidParamsConfig(
26
+ config: unknown,
27
+ ): config is PersistentParamsConfig {
28
+ if (config === null || config === undefined) {
29
+ return false;
30
+ }
31
+
32
+ // Array configuration: all items must be non-empty strings
33
+ if (Array.isArray(config)) {
34
+ return config.every((item) => {
35
+ if (typeof item !== "string" || item.length === 0) {
36
+ return false;
37
+ }
38
+
39
+ try {
40
+ validateParamKey(item);
41
+
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+ }
48
+
49
+ // Object configuration: must be plain object with primitive values
50
+ if (typeof config === "object") {
51
+ // Reject non-plain objects (Date, Map, etc.)
52
+ if (Object.getPrototypeOf(config) !== Object.prototype) {
53
+ return false;
54
+ }
55
+
56
+ // All keys must be non-empty strings, all values must be primitives
57
+ return Object.entries(config).every(([key, value]) => {
58
+ // Check key is non-empty string
59
+ if (typeof key !== "string" || key.length === 0) {
60
+ return false;
61
+ }
62
+
63
+ // Validate key doesn't contain special characters
64
+ try {
65
+ validateParamKey(key);
66
+ } catch {
67
+ return false;
68
+ }
69
+
70
+ // Validate value is primitive (NaN/Infinity already rejected by isPrimitiveValue)
71
+ return isPrimitiveValue(value);
72
+ });
73
+ }
74
+
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Validates parameter value before persisting.
80
+ * Throws descriptive TypeError if value is not valid for URL parameters.
81
+ *
82
+ * @param key - Parameter name for error messages
83
+ * @param value - Value to validate
84
+ * @throws {TypeError} If value is null, array, object, or other non-primitive type
85
+ */
86
+ export function validateParamValue(key: string, value: unknown): void {
87
+ if (value === null) {
88
+ throw new TypeError(
89
+ `[@real-router/persistent-params-plugin] Parameter "${key}" cannot be null. ` +
90
+ `Use undefined to remove the parameter from persistence.`,
91
+ );
92
+ }
93
+
94
+ if (value !== undefined && !isPrimitiveValue(value)) {
95
+ const actualType = Array.isArray(value) ? "array" : typeof value;
96
+
97
+ throw new TypeError(
98
+ `[@real-router/persistent-params-plugin] Parameter "${key}" must be a primitive value ` +
99
+ `(string, number, or boolean), got ${actualType}. ` +
100
+ `Objects and arrays are not supported in URL parameters.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Validates the params configuration and throws a descriptive error if invalid.
107
+ *
108
+ * @param params - Configuration to validate
109
+ * @throws {TypeError} If params is not a valid configuration
110
+ */
111
+ export function validateConfig(params: unknown): void {
112
+ if (!isValidParamsConfig(params)) {
113
+ let actualType: string;
114
+
115
+ if (params === null) {
116
+ actualType = "null";
117
+ } else if (Array.isArray(params)) {
118
+ actualType = "array with invalid items";
119
+ } else {
120
+ actualType = typeof params;
121
+ }
122
+
123
+ throw new TypeError(
124
+ `[@real-router/persistent-params-plugin] Invalid params configuration. ` +
125
+ `Expected array of non-empty strings or object with primitive values, got ${actualType}.`,
126
+ );
127
+ }
128
+ }
package/src/constants.ts DELETED
@@ -1,7 +0,0 @@
1
- // packages/persistent-params-plugin/modules/constants.ts
2
-
3
- /**
4
- * Symbol to mark router as initialized with this plugin.
5
- * Prevents double initialization and memory leaks from method wrapping.
6
- */
7
- export const PLUGIN_MARKER = Symbol("persistent-params-plugin");