Skip to content
Open
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
58 changes: 57 additions & 1 deletion packages/common/src/utils/objects.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applyDefaults, cloneDeepOnlyCloneableValues } from './objects';
import { applyDefaults, cloneDeepOnlyCloneableValues, freezeDeep } from './objects';

describe('applyDefaults', () => {
it('should recursively assign defaults for properties which are undefined', () => {
Expand Down Expand Up @@ -74,3 +74,59 @@ describe('cloneDeepOnlyCloneableValues', () => {
expect(clone[3]).toBe(source[3]);
});
});

describe('freezeDeep', () => {
it('should return the same object reference', () => {
const obj = { foo: 1 };

expect(freezeDeep(obj)).toBe(obj);
});

it('should freeze the top-level object', () => {
const obj = { foo: 1 };
freezeDeep(obj);

expect(Object.isFrozen(obj)).toBe(true);
});

it('should freeze nested objects', () => {
const obj = { foo: { bar: { baz: true } } };
freezeDeep(obj);

expect(Object.isFrozen(obj.foo)).toBe(true);
expect(Object.isFrozen(obj.foo.bar)).toBe(true);
});

it('should freeze nested arrays', () => {
const obj = { foo: [1, 2, 3] };
freezeDeep(obj);

expect(Object.isFrozen(obj.foo)).toBe(true);
});

it('should freeze objects nested within arrays', () => {
const obj = { items: [{ value: 'test' }] };
freezeDeep(obj);

expect(Object.isFrozen(obj.items[0])).toBe(true);
});

it('should handle non-enumerable properties', () => {
const hidden = { secret: true };
const obj = {};

Object.defineProperty(obj, 'hidden', { value: hidden, enumerable: false });
freezeDeep(obj);

expect(Object.isFrozen(hidden)).toBe(true);
});

it('should not fail on objects with circular references', () => {
const obj: Record<string, unknown> = { foo: 1 };
obj.self = obj;
freezeDeep(obj);

expect(Object.isFrozen(obj)).toBe(true);
expect(Object.isFrozen(obj.self)).toBe(true);
});
});
18 changes: 18 additions & 0 deletions packages/common/src/utils/objects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cloneDeepWith, defaultsDeep, forOwn, isPlainObject } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import type { AnyObject } from '../types/common';

/**
Expand Down Expand Up @@ -49,3 +50,20 @@ export const cloneDeepOnlyCloneableValues = <TObject>(obj: TObject): TObject =>
? value
: undefined;
});

/**
* Recursively freeze `obj` using `Object.freeze` and return the frozen object.
*/
export const freezeDeep = <T extends object>(obj: T): ReadonlyDeep<T> => {
Object.freeze(obj);

Object.getOwnPropertyNames(obj).forEach((key) => {
const value = (obj as AnyObject)[key];

if (value && typeof value === 'object' && !Object.isFrozen(value)) {
freezeDeep(value);
}
});

return obj as ReadonlyDeep<T>;
};
4 changes: 4 additions & 0 deletions packages/lib-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
## 9.0.0 - TBD

- BREAKING: Modify `useResolvedExtensions` hook to accept `extensions` array as a parameter ([#313])
- Deep freeze plugin manifest object when adding plugins to the `PluginStore` ([#325])
- Add optional peer dependency on `type-fest` ([#325])
- Expose `visitDeep` utility function ([#313])
- Expose `freezeDeep` utility function ([#325])

## 8.2.0 - 2026-03-19

Expand Down Expand Up @@ -144,3 +147,4 @@
[#309]: https://github.com/openshift/dynamic-plugin-sdk/pull/309
[#310]: https://github.com/openshift/dynamic-plugin-sdk/pull/310
[#313]: https://github.com/openshift/dynamic-plugin-sdk/pull/313
[#325]: https://github.com/openshift/dynamic-plugin-sdk/pull/325
8 changes: 7 additions & 1 deletion packages/lib-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
"api-extractor-local": "yarn run api-extractor --local"
},
"peerDependencies": {
"react": "^18 || ^19"
"react": "^18 || ^19",
"type-fest": "^2.18.0"
},
"peerDependenciesMeta": {
"type-fest": {
"optional": true
}
},
"dependencies": {
"lodash": "^4.17.23",
Expand Down
1 change: 1 addition & 0 deletions packages/lib-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
CustomError,
EitherNotBoth,
EitherOrNone,
freezeDeep,
LogFunction,
Logger,
Never,
Expand Down
17 changes: 12 additions & 5 deletions packages/lib-core/src/runtime/PluginStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AnyObject, EitherNotBoth } from '@monorepo/common';
import { consoleLogger, ErrorWithCause } from '@monorepo/common';
import { consoleLogger, freezeDeep, ErrorWithCause } from '@monorepo/common';
import { compact, isEqual, noop, pickBy } from 'lodash';
import { version as sdkVersion } from '../../package.json';
import type { LoadedExtension } from '../types/extension';
Expand Down Expand Up @@ -301,7 +301,10 @@ export class PluginStore implements PluginStoreInterface {

protected addPendingPlugin(manifest: PluginManifest) {
const pluginName = manifest.name;
const pendingPlugin: PendingPlugin = { manifest };

const pendingPlugin: PendingPlugin = {
manifest: freezeDeep(manifest),
};

this.pendingPlugins.set(pluginName, pendingPlugin);
this.loadedPlugins.delete(pluginName);
Expand All @@ -324,8 +327,7 @@ export class PluginStore implements PluginStoreInterface {
const pluginName = manifest.name;

const loadedPlugin: LoadedPlugin = {
// TODO(vojtech): use deepFreeze on the manifest and type it as DeepReadonly
manifest: Object.freeze(manifest),
manifest: freezeDeep(manifest),
loadedExtensions: loadedExtensions.map((e) => Object.freeze(e)),
entryModule,
enabled: false,
Expand All @@ -341,7 +343,12 @@ export class PluginStore implements PluginStoreInterface {

protected addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown) {
const pluginName = manifest.name;
const failedPlugin: FailedPlugin = { manifest, errorMessage, errorCause };

const failedPlugin: FailedPlugin = {
manifest: freezeDeep(manifest),
errorMessage,
errorCause,
};

this.pendingPlugins.delete(pluginName);
this.loadedPlugins.delete(pluginName);
Expand Down
9 changes: 5 additions & 4 deletions packages/lib-core/src/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReadonlyDeep } from 'type-fest';
import type { Extension, LoadedExtension } from './extension';
import type { PluginEntryModule } from './runtime';

Expand Down Expand Up @@ -90,15 +91,15 @@ export type PluginManifest = RemotePluginManifest | LocalPluginManifest;
* Internal entry on a plugin in `pending` state.
*/
export type PendingPlugin = {
manifest: Readonly<PluginManifest>;
manifest: ReadonlyDeep<PluginManifest>;
};

/**
* Internal entry on a plugin in `loaded` state.
*/
export type LoadedPlugin = {
manifest: Readonly<PluginManifest>;
loadedExtensions: Readonly<LoadedExtension[]>;
manifest: ReadonlyDeep<PluginManifest>;
loadedExtensions: Readonly<Readonly<LoadedExtension>[]>;
entryModule?: PluginEntryModule;
enabled: boolean;
disableReason?: string;
Expand All @@ -108,7 +109,7 @@ export type LoadedPlugin = {
* Internal entry on a plugin in `failed` state.
*/
export type FailedPlugin = {
manifest: Readonly<PluginManifest>;
manifest: ReadonlyDeep<PluginManifest>;
errorMessage: string;
errorCause?: unknown;
};
9 changes: 7 additions & 2 deletions packages/lib-webpack/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# Changelog for `@openshift/dynamic-plugin-sdk-webpack`

## 5.3.0 - TBD

- Add optional peer dependency on `type-fest` ([#325])

## 5.2.0 - 2026-06-11

- Add rspack compatibility: `DynamicRemotePlugin` now works with both webpack and rspack bundlers. Both
`@rspack/core` and `webpack` are now optional peer dependencies. ([#285])
- Update `DynamicRemotePlugin` to work with both webpack and rspack module bundlers ([#285])
- `@rspack/core` and `webpack` peer dependencies are now optional ([#285])

## 5.1.1 - 2026-04-16

Expand Down Expand Up @@ -86,3 +90,4 @@
[#289]: https://github.com/openshift/dynamic-plugin-sdk/pull/289
[#296]: https://github.com/openshift/dynamic-plugin-sdk/pull/296
[#314]: https://github.com/openshift/dynamic-plugin-sdk/pull/314
[#325]: https://github.com/openshift/dynamic-plugin-sdk/pull/325
4 changes: 4 additions & 0 deletions packages/lib-webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@
},
"peerDependencies": {
"@rspack/core": "^2.0.8",
"type-fest": "^2.18.0",
"webpack": "^5.100.0"
},
"peerDependenciesMeta": {
"@rspack/core": {
"optional": true
},
"type-fest": {
"optional": true
},
"webpack": {
"optional": true
}
Expand Down
12 changes: 8 additions & 4 deletions reports/lib-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { FC } from 'react';
import type { PropsWithChildren } from 'react';
import type { ReadonlyDeep } from 'type-fest';

// @public
export type AnyObject = Record<string, unknown>;
Expand Down Expand Up @@ -77,7 +78,7 @@ export type ExtractExtensionProperties<T> = T extends Extension<any, infer TProp

// @public
export type FailedPlugin = {
manifest: Readonly<PluginManifest>;
manifest: ReadonlyDeep<PluginManifest>;
errorMessage: string;
errorCause?: unknown;
};
Expand All @@ -95,6 +96,9 @@ export type FeatureFlags = {
// @public
export type FeatureFlagValue = boolean | undefined;

// @public
export const freezeDeep: <T extends object>(obj: T) => ReadonlyDeep<T>;

// @public
export const isCodeRef: (obj: unknown) => obj is CodeRef;

Expand All @@ -112,8 +116,8 @@ export type LoadedExtension<TExtension extends Extension = Extension> = TExtensi

// @public
export type LoadedPlugin = {
manifest: Readonly<PluginManifest>;
loadedExtensions: Readonly<LoadedExtension[]>;
manifest: ReadonlyDeep<PluginManifest>;
loadedExtensions: Readonly<Readonly<LoadedExtension>[]>;
entryModule?: PluginEntryModule;
enabled: boolean;
disableReason?: string;
Expand Down Expand Up @@ -159,7 +163,7 @@ export const parseEncodedCodeRef: (ref: EncodedCodeRef) => {

// @public
export type PendingPlugin = {
manifest: Readonly<PluginManifest>;
manifest: ReadonlyDeep<PluginManifest>;
};

// @public
Expand Down
21 changes: 21 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1144,10 +1144,13 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
"@rspack/core": ^2.0.8
type-fest: ^2.18.0
webpack: ^5.100.0
peerDependenciesMeta:
"@rspack/core":
optional: true
type-fest:
optional: true
webpack:
optional: true
languageName: node
Expand All @@ -1162,10 +1165,13 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
"@rspack/core": ^2.0.8
type-fest: ^2.18.0
webpack: ^5.100.0
peerDependenciesMeta:
"@rspack/core":
optional: true
type-fest:
optional: true
webpack:
optional: true
languageName: node
Expand All @@ -1180,10 +1186,13 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
"@rspack/core": ^2.0.8
type-fest: ^2.18.0
webpack: ^5.100.0
peerDependenciesMeta:
"@rspack/core":
optional: true
type-fest:
optional: true
webpack:
optional: true
languageName: unknown
Expand All @@ -1199,6 +1208,10 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
react: ^18 || ^19
type-fest: ^2.18.0
peerDependenciesMeta:
type-fest:
optional: true
languageName: node
linkType: soft

Expand All @@ -1212,6 +1225,10 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
react: ^18 || ^19
type-fest: ^2.18.0
peerDependenciesMeta:
type-fest:
optional: true
languageName: node
linkType: soft

Expand All @@ -1225,6 +1242,10 @@ __metadata:
yup: "npm:^1.7.1"
peerDependencies:
react: ^18 || ^19
type-fest: ^2.18.0
peerDependenciesMeta:
type-fest:
optional: true
languageName: unknown
linkType: soft

Expand Down