@memberjunction/react-runtime 5.40.2 → 5.41.0

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.
@@ -0,0 +1,7 @@
1
+ export declare const USER_STATE_KEY_PREFIX = "InteractiveComponents_UserState_Root/";
2
+ export declare function resolveUserStateScope(explicitScope: string | undefined | null, namespace: string | undefined | null, name: string | undefined | null): string | null;
3
+ export declare function userStateStorageKey(scope: string | null): string | null;
4
+ export declare function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown>;
5
+ export declare function mergeUserSettings(hostDefaults: Record<string, unknown> | undefined | null, stored: Record<string, unknown> | undefined | null): Record<string, unknown>;
6
+ export declare function applyUserSettingsUpdate(current: Record<string, unknown> | undefined | null, incoming: Record<string, unknown> | undefined | null): Record<string, unknown>;
7
+ //# sourceMappingURL=user-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-state.d.ts","sourceRoot":"","sources":["../../src/utilities/user-state.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,qBAAqB,0CAA0C,CAAC;AAiB7E,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACxC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACpC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAC9B,MAAM,GAAG,IAAI,CAYf;AAMD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAEvE;AAOD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa/F;AAOD,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,GACjD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzB;AAsBD,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACnD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,GACnD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUzB"}
@@ -0,0 +1,48 @@
1
+ export const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';
2
+ export function resolveUserStateScope(explicitScope, namespace, name) {
3
+ const explicit = explicitScope?.trim();
4
+ if (explicit) {
5
+ return explicit.toLowerCase();
6
+ }
7
+ const cleanName = name?.trim();
8
+ if (!cleanName) {
9
+ return null;
10
+ }
11
+ const cleanNamespace = namespace?.trim();
12
+ const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;
13
+ return scope.toLowerCase();
14
+ }
15
+ export function userStateStorageKey(scope) {
16
+ return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;
17
+ }
18
+ export function parseStoredUserSettings(raw) {
19
+ if (!raw) {
20
+ return {};
21
+ }
22
+ try {
23
+ const parsed = JSON.parse(raw);
24
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
25
+ return parsed;
26
+ }
27
+ return {};
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ export function mergeUserSettings(hostDefaults, stored) {
34
+ return { ...(hostDefaults ?? {}), ...(stored ?? {}) };
35
+ }
36
+ export function applyUserSettingsUpdate(current, incoming) {
37
+ const next = { ...(current ?? {}) };
38
+ for (const [key, value] of Object.entries(incoming ?? {})) {
39
+ if (value === null || value === undefined) {
40
+ delete next[key];
41
+ }
42
+ else {
43
+ next[key] = value;
44
+ }
45
+ }
46
+ return next;
47
+ }
48
+ //# sourceMappingURL=user-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-state.js","sourceRoot":"","sources":["../../src/utilities/user-state.ts"],"names":[],"mappings":"AAoBA,MAAM,CAAC,MAAM,qBAAqB,GAAG,uCAAuC,CAAC;AAiB7E,MAAM,UAAU,qBAAqB,CACnC,aAAwC,EACxC,SAAoC,EACpC,IAA+B;IAE/B,MAAM,QAAQ,GAAG,aAAa,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,QAAQ,EAAE,CAAC;QACb,OAAO,QAAQ,CAAC,WAAW,EAAE,CAAC;IAChC,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,EAAE,IAAI,EAAE,CAAC;IAC/B,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,cAAc,GAAG,SAAS,EAAE,IAAI,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5E,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC;AAC7B,CAAC;AAMD,MAAM,UAAU,mBAAmB,CAAC,KAAoB;IACtD,OAAO,KAAK,CAAC,CAAC,CAAC,GAAG,qBAAqB,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC3D,CAAC;AAOD,MAAM,UAAU,uBAAuB,CAAC,GAA8B;IACpE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACnE,OAAO,MAAiC,CAAC;QAC3C,CAAC;QACD,OAAO,EAAE,CAAC;IACZ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAOD,MAAM,UAAU,iBAAiB,CAC/B,YAAwD,EACxD,MAAkD;IAElD,OAAO,EAAE,GAAG,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,CAAC;AACxD,CAAC;AAsBD,MAAM,UAAU,uBAAuB,CACrC,OAAmD,EACnD,QAAoD;IAEpD,MAAM,IAAI,GAA4B,EAAE,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE,CAAC;IAC7D,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YAC1C,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QACpB,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["/**\n * @fileoverview Helpers for the per-user interactive-component settings contract\n * (`savedUserSettings` in / `onSaveUserSettings` out).\n *\n * These helpers are intentionally **pure and framework-agnostic** so the Angular\n * host bridge and the Node test harness share one implementation and the scoping /\n * serialization logic can be unit tested in isolation. The host wires these to a\n * durable per-user store (e.g. `UserInfoEngine`); the runtime itself never persists.\n *\n * @module @memberjunction/react-runtime\n */\n\n/**\n * Prefix applied to every interactive-component user-settings storage key. The\n * full key is `InteractiveComponents_UserState_Root/<scope>`, where `<scope>` is\n * resolved by {@link resolveUserStateScope}. It is deliberately long and unique\n * to avoid collisions with other namespaced keys in the shared\n * `MJ: User Settings` keyspace. Keeping the prefix in one place also avoids\n * stringly-typed drift between the seed (read) and persist (write) paths.\n */\nexport const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';\n\n/**\n * Resolve the stable per-component scope used to namespace a component's\n * persisted user settings.\n *\n * An explicit, host-supplied scope always wins (used when a host needs to\n * differentiate two instances of the same component spec — e.g. scope a form's\n * settings by the entity it edits). Otherwise the scope is derived from the\n * component spec's `namespace` + `name`.\n *\n * The result is lowercased to avoid case-variant duplicate rows in the settings\n * store (per MJ's user-settings key convention).\n *\n * @returns the resolved scope, or `null` when no stable scope can be derived\n * (e.g. an unnamed component) — signaling the caller to skip persistence.\n */\nexport function resolveUserStateScope(\n explicitScope: string | undefined | null,\n namespace: string | undefined | null,\n name: string | undefined | null\n): string | null {\n const explicit = explicitScope?.trim();\n if (explicit) {\n return explicit.toLowerCase();\n }\n const cleanName = name?.trim();\n if (!cleanName) {\n return null;\n }\n const cleanNamespace = namespace?.trim();\n const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;\n return scope.toLowerCase();\n}\n\n/**\n * Build the full storage key for a resolved scope, or `null` when the scope is\n * `null` (persistence should be skipped).\n */\nexport function userStateStorageKey(scope: string | null): string | null {\n return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;\n}\n\n/**\n * Safely parse a stored settings blob into a plain object. Returns `{}` for\n * null/empty input, invalid JSON, or any non-object JSON (arrays, primitives) —\n * persisted user settings are always a flat key/value object.\n */\nexport function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown> {\n if (!raw) {\n return {};\n }\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n return {};\n } catch {\n return {};\n }\n}\n\n/**\n * Merge host-provided defaults with the stored per-user settings. Stored values\n * win, so a returning user sees their saved preferences while a host can still\n * seed sensible first-run defaults that fill any gaps.\n */\nexport function mergeUserSettings(\n hostDefaults: Record<string, unknown> | undefined | null,\n stored: Record<string, unknown> | undefined | null\n): Record<string, unknown> {\n return { ...(hostDefaults ?? {}), ...(stored ?? {}) };\n}\n\n/**\n * Apply an `onSaveUserSettings` payload to the host's current settings snapshot.\n *\n * **Merge, not replace.** The contract asks components to pass the complete\n * settings object, but the host must be resilient to a component (especially an\n * AI-generated one) passing only the changed keys — and to the stale-prop case\n * where a component spreads the mount-time `savedUserSettings` prop, which the\n * host deliberately never refreshes mid-session (no re-render on save). Under\n * full-replace semantics either slip silently wipes every other saved\n * preference; under merge the worst case is a no-op.\n *\n * **Removing a key requires explicit intent**: set its value to `null` and the\n * key is deleted from the snapshot (reads fall back to defaults via the\n * documented `savedUserSettings?.key ?? fallback` pattern). `undefined` values\n * are treated the same way, since `JSON.stringify` would drop them from the\n * persisted blob anyway and the in-memory snapshot must stay consistent with\n * what is stored.\n *\n * @returns a new object — neither input is mutated.\n */\nexport function applyUserSettingsUpdate(\n current: Record<string, unknown> | undefined | null,\n incoming: Record<string, unknown> | undefined | null\n): Record<string, unknown> {\n const next: Record<string, unknown> = { ...(current ?? {}) };\n for (const [key, value] of Object.entries(incoming ?? {})) {\n if (value === null || value === undefined) {\n delete next[key];\n } else {\n next[key] = value;\n }\n }\n return next;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memberjunction/react-runtime",
3
- "version": "5.40.2",
3
+ "version": "5.41.0",
4
4
  "description": "Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,11 +29,11 @@
29
29
  },
30
30
  "homepage": "https://github.com/MemberJunction/MJ#readme",
31
31
  "dependencies": {
32
- "@memberjunction/core": "5.40.2",
33
- "@memberjunction/global": "5.40.2",
34
- "@memberjunction/interactive-component-types": "5.40.2",
35
- "@memberjunction/core-entities": "5.40.2",
36
- "@memberjunction/graphql-dataprovider": "5.40.2",
32
+ "@memberjunction/core": "5.41.0",
33
+ "@memberjunction/global": "5.41.0",
34
+ "@memberjunction/interactive-component-types": "5.41.0",
35
+ "@memberjunction/core-entities": "5.41.0",
36
+ "@memberjunction/graphql-dataprovider": "5.41.0",
37
37
  "@babel/standalone": "^7.29.1",
38
38
  "rxjs": "^7.8.2"
39
39
  },
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ USER_STATE_KEY_PREFIX,
4
+ resolveUserStateScope,
5
+ userStateStorageKey,
6
+ parseStoredUserSettings,
7
+ mergeUserSettings,
8
+ applyUserSettingsUpdate
9
+ } from '../utilities/user-state';
10
+
11
+ describe('user-state helpers', () => {
12
+ describe('resolveUserStateScope', () => {
13
+ it('prefers an explicit scope over namespace/name', () => {
14
+ expect(resolveUserStateScope('Form/Contacts', 'crm', 'ContactForm')).toBe('form/contacts');
15
+ });
16
+
17
+ it('lowercases the explicit scope', () => {
18
+ expect(resolveUserStateScope('MyDashboard', null, null)).toBe('mydashboard');
19
+ });
20
+
21
+ it('trims surrounding whitespace from the explicit scope', () => {
22
+ expect(resolveUserStateScope(' Spaced ', null, null)).toBe('spaced');
23
+ });
24
+
25
+ it('combines namespace and name when no explicit scope is given', () => {
26
+ expect(resolveUserStateScope(undefined, 'CRM/Analytics', 'AccountDashboard'))
27
+ .toBe('crm/analytics/accountdashboard');
28
+ });
29
+
30
+ it('uses just the name when there is no namespace', () => {
31
+ expect(resolveUserStateScope(undefined, undefined, 'SalesReport')).toBe('salesreport');
32
+ expect(resolveUserStateScope(null, ' ', 'SalesReport')).toBe('salesreport');
33
+ });
34
+
35
+ it('returns null when no stable scope can be derived', () => {
36
+ expect(resolveUserStateScope(undefined, undefined, undefined)).toBeNull();
37
+ expect(resolveUserStateScope('', '', '')).toBeNull();
38
+ expect(resolveUserStateScope(' ', 'crm', ' ')).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('userStateStorageKey', () => {
43
+ it('prefixes a resolved scope with the unique root namespace', () => {
44
+ expect(userStateStorageKey('form/contacts')).toBe(`${USER_STATE_KEY_PREFIX}form/contacts`);
45
+ expect(userStateStorageKey('form/contacts')).toBe('InteractiveComponents_UserState_Root/form/contacts');
46
+ });
47
+
48
+ it('returns null for a null scope', () => {
49
+ expect(userStateStorageKey(null)).toBeNull();
50
+ });
51
+ });
52
+
53
+ describe('parseStoredUserSettings', () => {
54
+ it('parses a valid JSON object', () => {
55
+ expect(parseStoredUserSettings('{"sortBy":"Name","page":2}')).toEqual({ sortBy: 'Name', page: 2 });
56
+ });
57
+
58
+ it('returns an empty object for null/undefined/empty input', () => {
59
+ expect(parseStoredUserSettings(null)).toEqual({});
60
+ expect(parseStoredUserSettings(undefined)).toEqual({});
61
+ expect(parseStoredUserSettings('')).toEqual({});
62
+ });
63
+
64
+ it('returns an empty object for invalid JSON instead of throwing', () => {
65
+ expect(parseStoredUserSettings('{not valid json')).toEqual({});
66
+ });
67
+
68
+ it('returns an empty object for non-object JSON (arrays, primitives)', () => {
69
+ expect(parseStoredUserSettings('[1,2,3]')).toEqual({});
70
+ expect(parseStoredUserSettings('42')).toEqual({});
71
+ expect(parseStoredUserSettings('"hello"')).toEqual({});
72
+ expect(parseStoredUserSettings('null')).toEqual({});
73
+ });
74
+ });
75
+
76
+ describe('mergeUserSettings', () => {
77
+ it('lets stored values win over host defaults', () => {
78
+ const merged = mergeUserSettings({ sortBy: 'CloseDate', viewMode: 'grid' }, { sortBy: 'Name' });
79
+ expect(merged).toEqual({ sortBy: 'Name', viewMode: 'grid' });
80
+ });
81
+
82
+ it('keeps host defaults that the stored settings do not override', () => {
83
+ expect(mergeUserSettings({ a: 1, b: 2 }, { b: 3 })).toEqual({ a: 1, b: 3 });
84
+ });
85
+
86
+ it('handles null/undefined on either side', () => {
87
+ expect(mergeUserSettings(null, { x: 1 })).toEqual({ x: 1 });
88
+ expect(mergeUserSettings({ x: 1 }, null)).toEqual({ x: 1 });
89
+ expect(mergeUserSettings(null, null)).toEqual({});
90
+ expect(mergeUserSettings(undefined, undefined)).toEqual({});
91
+ });
92
+
93
+ it('returns a new object (does not mutate inputs)', () => {
94
+ const defaults = { a: 1 };
95
+ const stored = { b: 2 };
96
+ const merged = mergeUserSettings(defaults, stored);
97
+ expect(merged).not.toBe(defaults);
98
+ expect(merged).not.toBe(stored);
99
+ expect(defaults).toEqual({ a: 1 });
100
+ expect(stored).toEqual({ b: 2 });
101
+ });
102
+ });
103
+
104
+ describe('applyUserSettingsUpdate', () => {
105
+ it('overlays incoming keys onto the current snapshot', () => {
106
+ expect(applyUserSettingsUpdate({ viewMode: 'grid', sortBy: 'CloseDate' }, { sortBy: 'Name' }))
107
+ .toEqual({ viewMode: 'grid', sortBy: 'Name' });
108
+ });
109
+
110
+ it('preserves untouched keys when a component passes only a delta', () => {
111
+ // The AI-forgot-the-spread case: a delta must not wipe other preferences.
112
+ expect(applyUserSettingsUpdate({ viewMode: 'grid', page: 3 }, { sortBy: 'Name' }))
113
+ .toEqual({ viewMode: 'grid', page: 3, sortBy: 'Name' });
114
+ });
115
+
116
+ it('survives the stale-prop spread (two sequential saves keep both changes)', () => {
117
+ // Component spreads the mount-time prop both times; the host snapshot
118
+ // must still accumulate both changes.
119
+ const mountProp = { viewMode: 'grid' };
120
+ let snapshot = applyUserSettingsUpdate(mountProp, { ...mountProp, sortBy: 'Name' });
121
+ snapshot = applyUserSettingsUpdate(snapshot, { ...mountProp, viewMode: 'list' });
122
+ expect(snapshot).toEqual({ viewMode: 'list', sortBy: 'Name' });
123
+ });
124
+
125
+ it('removes a key when its incoming value is explicitly null', () => {
126
+ expect(applyUserSettingsUpdate({ sortBy: 'Name', viewMode: 'grid' }, { sortBy: null }))
127
+ .toEqual({ viewMode: 'grid' });
128
+ });
129
+
130
+ it('removes a key when its incoming value is undefined', () => {
131
+ expect(applyUserSettingsUpdate({ sortBy: 'Name', viewMode: 'grid' }, { sortBy: undefined }))
132
+ .toEqual({ viewMode: 'grid' });
133
+ });
134
+
135
+ it('treats a full-object payload as a superset (replace-equivalent)', () => {
136
+ expect(applyUserSettingsUpdate({ a: 1, b: 2 }, { a: 10, b: 20 })).toEqual({ a: 10, b: 20 });
137
+ });
138
+
139
+ it('handles null/undefined on either side', () => {
140
+ expect(applyUserSettingsUpdate(null, { x: 1 })).toEqual({ x: 1 });
141
+ expect(applyUserSettingsUpdate({ x: 1 }, null)).toEqual({ x: 1 });
142
+ expect(applyUserSettingsUpdate(null, null)).toEqual({});
143
+ });
144
+
145
+ it('returns a new object (does not mutate inputs)', () => {
146
+ const current = { a: 1 };
147
+ const incoming = { b: 2 };
148
+ const next = applyUserSettingsUpdate(current, incoming);
149
+ expect(next).not.toBe(current);
150
+ expect(next).not.toBe(incoming);
151
+ expect(current).toEqual({ a: 1 });
152
+ expect(incoming).toEqual({ b: 2 });
153
+ });
154
+ });
155
+ });
package/src/index.ts CHANGED
@@ -140,6 +140,15 @@ export {
140
140
  unwrapAllComponents
141
141
  } from './utilities/component-unwrapper';
142
142
 
143
+ export {
144
+ USER_STATE_KEY_PREFIX,
145
+ resolveUserStateScope,
146
+ userStateStorageKey,
147
+ parseStoredUserSettings,
148
+ mergeUserSettings,
149
+ applyUserSettingsUpdate
150
+ } from './utilities/user-state';
151
+
143
152
  // Version information
144
153
  export const VERSION = '2.69.1';
145
154
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @fileoverview Helpers for the per-user interactive-component settings contract
3
+ * (`savedUserSettings` in / `onSaveUserSettings` out).
4
+ *
5
+ * These helpers are intentionally **pure and framework-agnostic** so the Angular
6
+ * host bridge and the Node test harness share one implementation and the scoping /
7
+ * serialization logic can be unit tested in isolation. The host wires these to a
8
+ * durable per-user store (e.g. `UserInfoEngine`); the runtime itself never persists.
9
+ *
10
+ * @module @memberjunction/react-runtime
11
+ */
12
+
13
+ /**
14
+ * Prefix applied to every interactive-component user-settings storage key. The
15
+ * full key is `InteractiveComponents_UserState_Root/<scope>`, where `<scope>` is
16
+ * resolved by {@link resolveUserStateScope}. It is deliberately long and unique
17
+ * to avoid collisions with other namespaced keys in the shared
18
+ * `MJ: User Settings` keyspace. Keeping the prefix in one place also avoids
19
+ * stringly-typed drift between the seed (read) and persist (write) paths.
20
+ */
21
+ export const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';
22
+
23
+ /**
24
+ * Resolve the stable per-component scope used to namespace a component's
25
+ * persisted user settings.
26
+ *
27
+ * An explicit, host-supplied scope always wins (used when a host needs to
28
+ * differentiate two instances of the same component spec — e.g. scope a form's
29
+ * settings by the entity it edits). Otherwise the scope is derived from the
30
+ * component spec's `namespace` + `name`.
31
+ *
32
+ * The result is lowercased to avoid case-variant duplicate rows in the settings
33
+ * store (per MJ's user-settings key convention).
34
+ *
35
+ * @returns the resolved scope, or `null` when no stable scope can be derived
36
+ * (e.g. an unnamed component) — signaling the caller to skip persistence.
37
+ */
38
+ export function resolveUserStateScope(
39
+ explicitScope: string | undefined | null,
40
+ namespace: string | undefined | null,
41
+ name: string | undefined | null
42
+ ): string | null {
43
+ const explicit = explicitScope?.trim();
44
+ if (explicit) {
45
+ return explicit.toLowerCase();
46
+ }
47
+ const cleanName = name?.trim();
48
+ if (!cleanName) {
49
+ return null;
50
+ }
51
+ const cleanNamespace = namespace?.trim();
52
+ const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;
53
+ return scope.toLowerCase();
54
+ }
55
+
56
+ /**
57
+ * Build the full storage key for a resolved scope, or `null` when the scope is
58
+ * `null` (persistence should be skipped).
59
+ */
60
+ export function userStateStorageKey(scope: string | null): string | null {
61
+ return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;
62
+ }
63
+
64
+ /**
65
+ * Safely parse a stored settings blob into a plain object. Returns `{}` for
66
+ * null/empty input, invalid JSON, or any non-object JSON (arrays, primitives) —
67
+ * persisted user settings are always a flat key/value object.
68
+ */
69
+ export function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown> {
70
+ if (!raw) {
71
+ return {};
72
+ }
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
76
+ return parsed as Record<string, unknown>;
77
+ }
78
+ return {};
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Merge host-provided defaults with the stored per-user settings. Stored values
86
+ * win, so a returning user sees their saved preferences while a host can still
87
+ * seed sensible first-run defaults that fill any gaps.
88
+ */
89
+ export function mergeUserSettings(
90
+ hostDefaults: Record<string, unknown> | undefined | null,
91
+ stored: Record<string, unknown> | undefined | null
92
+ ): Record<string, unknown> {
93
+ return { ...(hostDefaults ?? {}), ...(stored ?? {}) };
94
+ }
95
+
96
+ /**
97
+ * Apply an `onSaveUserSettings` payload to the host's current settings snapshot.
98
+ *
99
+ * **Merge, not replace.** The contract asks components to pass the complete
100
+ * settings object, but the host must be resilient to a component (especially an
101
+ * AI-generated one) passing only the changed keys — and to the stale-prop case
102
+ * where a component spreads the mount-time `savedUserSettings` prop, which the
103
+ * host deliberately never refreshes mid-session (no re-render on save). Under
104
+ * full-replace semantics either slip silently wipes every other saved
105
+ * preference; under merge the worst case is a no-op.
106
+ *
107
+ * **Removing a key requires explicit intent**: set its value to `null` and the
108
+ * key is deleted from the snapshot (reads fall back to defaults via the
109
+ * documented `savedUserSettings?.key ?? fallback` pattern). `undefined` values
110
+ * are treated the same way, since `JSON.stringify` would drop them from the
111
+ * persisted blob anyway and the in-memory snapshot must stay consistent with
112
+ * what is stored.
113
+ *
114
+ * @returns a new object — neither input is mutated.
115
+ */
116
+ export function applyUserSettingsUpdate(
117
+ current: Record<string, unknown> | undefined | null,
118
+ incoming: Record<string, unknown> | undefined | null
119
+ ): Record<string, unknown> {
120
+ const next: Record<string, unknown> = { ...(current ?? {}) };
121
+ for (const [key, value] of Object.entries(incoming ?? {})) {
122
+ if (value === null || value === undefined) {
123
+ delete next[key];
124
+ } else {
125
+ next[key] = value;
126
+ }
127
+ }
128
+ return next;
129
+ }