From 2d5506cedeed7175c98b37e6bc3fe7a8dc152d68 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Mon, 1 Jun 2026 12:02:52 +0200 Subject: [PATCH 1/5] fix: refactor update data to avoid lodash issues Replace the lodash/fp/set call in UPDATE_DATA with a dedicated helper. This fixes issues with numeric segments (e.g. "group-key.15") and bracket characters in property names (e.g. "test[0]"). Fixes #2102 Fixes #2397 --- packages/core/src/reducers/core.ts | 14 +- packages/core/src/util/index.ts | 1 + packages/core/src/util/setData.ts | 187 ++++++++++++ packages/core/test/reducers/core.test.ts | 265 ++++++++++++++++++ .../src/examples/special-property-names.ts | 133 +++++++++ packages/examples/src/index.ts | 2 + 6 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/util/setData.ts create mode 100644 packages/examples/src/examples/special-property-names.ts diff --git a/packages/core/src/reducers/core.ts b/packages/core/src/reducers/core.ts index 3ec21a3dba..3c6900803a 100644 --- a/packages/core/src/reducers/core.ts +++ b/packages/core/src/reducers/core.ts @@ -24,10 +24,9 @@ */ import cloneDeep from 'lodash/cloneDeep'; -import setFp from 'lodash/fp/set'; -import unsetFp from 'lodash/fp/unset'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; +import { setDataAt, unsetDataAt } from '../util/setData'; import { CoreActions, INIT, @@ -242,15 +241,16 @@ export const coreReducer: Reducer = ( const newData = action.updater(cloneDeep(oldData)); let newState: any; if (newData !== undefined) { - newState = setFp( + newState = setDataAt( + state.data === undefined ? {} : state.data, action.path, newData, - state.data === undefined ? {} : state.data + state.schema ); } else { - newState = unsetFp( - action.path, - state.data === undefined ? {} : state.data + newState = unsetDataAt( + state.data === undefined ? {} : state.data, + action.path ); } const errors = validate(state.validator, newState); diff --git a/packages/core/src/util/index.ts b/packages/core/src/util/index.ts index 82917614b4..0d4ec3564f 100644 --- a/packages/core/src/util/index.ts +++ b/packages/core/src/util/index.ts @@ -28,6 +28,7 @@ export * from './ids'; export * from './label'; export * from './path'; export * from './resolvers'; +export * from './setData'; export * from './runtime'; export * from './schema'; export * from './uischema'; diff --git a/packages/core/src/util/setData.ts b/packages/core/src/util/setData.ts new file mode 100644 index 0000000000..9c85aa68ac --- /dev/null +++ b/packages/core/src/util/setData.ts @@ -0,0 +1,187 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ + +import type { JsonSchema } from '../models'; +import { encode } from './path'; +import { resolveSchema } from './resolvers'; +import { hasType } from './util'; + +const splitPath = (path: string): string[] => path.split('.'); + +/** + * Walks one step in the schema along the given data path segment, so we can + * tell whether a missing intermediate container should be created as an + * array or as an object. + */ +const stepSchema = ( + schema: JsonSchema | undefined, + segment: string, + rootSchema: JsonSchema | undefined +): JsonSchema | undefined => { + if (!schema || !rootSchema) { + return undefined; + } + const pointer = hasType(schema, 'array') + ? Array.isArray(schema.items) + ? `/items/${segment}` + : '/items' + : `/properties/${encode(segment)}`; + return resolveSchema(schema, pointer, rootSchema); +}; + +const cloneContainer = (data: any): any => { + if (Array.isArray(data)) { + return [...data]; + } + return { ...(data ?? {}) }; +}; + +const assign = (container: any, segment: string, value: any): any => { + if (Array.isArray(container)) { + const index = Number(segment); + container[Number.isInteger(index) ? index : (segment as any)] = value; + } else { + container[segment] = value; + } + return container; +}; + +/** + * Immutably sets `value` at the dotted `path` within `data`. + * + * Numeric path segments and segments containing bracket notation are treated + * as plain object property names. The optional `rootSchema` is consulted when + * a new intermediate container has to be created, so that arrays are still + * created where the schema declares an array type. + */ +export const setDataAt = ( + data: any, + path: string, + value: any, + rootSchema?: JsonSchema +): any => { + const segments = splitPath(path); + if (segments.length === 0) { + return value; + } + return doSet(data, segments, 0, value, rootSchema, rootSchema); +}; + +const doSet = ( + data: any, + segments: string[], + index: number, + value: any, + currentSchema: JsonSchema | undefined, + rootSchema: JsonSchema | undefined +): any => { + const segment = segments[index]; + const childSchema = stepSchema(currentSchema, segment, rootSchema); + const container = cloneContainer(data); + + if (index === segments.length - 1) { + return assign(container, segment, value); + } + + const existingChild = data?.[segment]; + let nextValue; + if ( + existingChild !== undefined && + existingChild !== null && + typeof existingChild === 'object' + ) { + nextValue = doSet( + existingChild, + segments, + index + 1, + value, + childSchema, + rootSchema + ); + } else { + const initial = hasType(childSchema, 'array') ? [] : {}; + nextValue = doSet( + initial, + segments, + index + 1, + value, + childSchema, + rootSchema + ); + } + return assign(container, segment, nextValue); +}; + +/** + * Immutably unsets the value at the dotted `path` within `data`. + * + * Numeric path segments and bracket notation in segments are treated as + * plain object property names, mirroring the semantics of {@link setDataAt}. + */ +export const unsetDataAt = (data: any, path: string): any => { + const segments = splitPath(path); + if (segments.length === 0) { + return data; + } + return doUnset(data, segments, 0); +}; + +const doUnset = (data: any, segments: string[], index: number): any => { + if (data === undefined || data === null) { + return data; + } + const segment = segments[index]; + if (index === segments.length - 1) { + if (Array.isArray(data)) { + const container = [...data]; + const numericIndex = Number(segment); + if (Number.isInteger(numericIndex)) { + delete container[numericIndex]; + } + return container; + } + if (!Object.prototype.hasOwnProperty.call(data, segment)) { + return data; + } + const container: { [key: string]: any } = { ...data }; + delete container[segment]; + return container; + } + + const existingChild = data[segment]; + if ( + existingChild === undefined || + existingChild === null || + typeof existingChild !== 'object' + ) { + return data; + } + const nextValue = doUnset(existingChild, segments, index + 1); + if (nextValue === existingChild) { + return data; + } + const container = cloneContainer(data); + return assign(container, segment, nextValue); +}; diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index df5e9a51e2..3f03756abc 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -595,6 +595,271 @@ test('core reducer - update - setting a state slice as undefined should remove t t.deepEqual(Object.keys(after.data), ['fizz']); }); +test('core reducer - update - numeric property key on nested object should not be treated as array index (#2397)', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + 'group-key': { + type: 'object', + properties: { + '15': { + type: 'string', + }, + }, + }, + }, + }; + + const before: JsonFormsCore = { + data: {}, + schema, + uischema: { + type: 'Label', + }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const after = coreReducer( + before, + update('group-key.15', () => 'something') + ); + + t.false(Array.isArray((after.data as any)['group-key'])); + t.deepEqual(after.data, { 'group-key': { '15': 'something' } }); +}); + +test('core reducer - update - property name containing brackets should be treated as a single key (#2102)', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + 'test[0]': { type: 'string' }, + 'object[0]': { + type: 'object', + properties: { + test: { type: 'string' }, + }, + }, + }, + }; + + const before: JsonFormsCore = { + data: {}, + schema, + uischema: { + type: 'Label', + }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const afterFirst = coreReducer( + before, + update('test[0]', () => 'TEST') + ); + t.deepEqual(afterFirst.data, { 'test[0]': 'TEST' }); + + const afterSecond = coreReducer( + afterFirst, + update('object[0].test', () => 'NESTED') + ); + t.deepEqual(afterSecond.data, { + 'test[0]': 'TEST', + 'object[0]': { test: 'NESTED' }, + }); +}); + +test('core reducer - update - array elements addressed by numeric segment continue to work', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }; + + const before: JsonFormsCore = { + data: { items: [{ name: 'a' }, { name: 'b' }] }, + schema, + uischema: { + type: 'Label', + }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const after = coreReducer( + before, + update('items.1.name', () => 'updated') + ); + + t.true(Array.isArray((after.data as any).items)); + t.is((after.data as any).items.length, 2); + t.deepEqual(after.data, { items: [{ name: 'a' }, { name: 'updated' }] }); +}); + +test('core reducer - update - rebuilds containers along the path while keeping sibling references untouched', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + parent: { + type: 'object', + properties: { + target: { type: 'string' }, + siblingObject: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + }, + untouchedObject: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + }; + + const siblingObject = { value: 'untouched-nested' }; + const untouchedObject = { value: 'untouched-root' }; + + const before: JsonFormsCore = { + data: { + parent: { target: 'old', siblingObject }, + untouchedObject, + }, + schema, + uischema: { type: 'Label' }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const after = coreReducer( + before, + update('parent.target', () => 'new') + ); + + // Containers on the path are fresh references + t.not(after.data, before.data); + t.not((after.data as any).parent, (before.data as any).parent); + // Siblings on those containers keep their original references + t.is((after.data as any).parent.siblingObject, siblingObject); + t.is((after.data as any).untouchedObject, untouchedObject); + t.is((after.data as any).parent.target, 'new'); +}); + +test('core reducer - update - creates an array when the schema declares one even if the segment is non-numeric in the parent', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + list: { + type: 'array', + items: { type: 'string' }, + }, + }, + }; + + const before: JsonFormsCore = { + data: {}, + schema, + uischema: { + type: 'Label', + }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const after = coreReducer( + before, + update('list.0', () => 'first') + ); + + t.true(Array.isArray((after.data as any).list)); + t.deepEqual(after.data, { list: ['first'] }); +}); + +test('core reducer - update - creates a schema-declared array of objects when the path traverses a missing array', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + }; + + const before: JsonFormsCore = { + data: {}, + schema, + uischema: { type: 'Label' }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const after = coreReducer( + before, + update('users.0.name', () => 'Alice') + ); + + t.true(Array.isArray((after.data as any).users)); + t.is((after.data as any).users.length, 1); + t.deepEqual(after.data, { users: [{ name: 'Alice' }] }); +}); + +test('core reducer - update - unset works for numeric and bracket-containing keys', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + 'group-key': { + type: 'object', + properties: { + '15': { type: 'string' }, + 'non-numeric-key': { type: 'string' }, + }, + }, + 'test[0]': { type: 'string' }, + }, + }; + + const before: JsonFormsCore = { + data: { + 'group-key': { '15': 'fifteen', 'non-numeric-key': 'kept' }, + 'test[0]': 'TEST', + }, + schema, + uischema: { type: 'Label' }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const afterNumeric = coreReducer( + before, + update('group-key.15', () => undefined) + ); + t.deepEqual(afterNumeric.data, { + 'group-key': { 'non-numeric-key': 'kept' }, + 'test[0]': 'TEST', + }); + + const afterBracket = coreReducer( + afterNumeric, + update('test[0]', () => undefined) + ); + t.deepEqual(afterBracket.data, { + 'group-key': { 'non-numeric-key': 'kept' }, + }); +}); + test('core reducer - updateErrors - should update errors with empty list', (t) => { const before: JsonFormsCore = { data: {}, diff --git a/packages/examples/src/examples/special-property-names.ts b/packages/examples/src/examples/special-property-names.ts new file mode 100644 index 0000000000..8e2aa938d7 --- /dev/null +++ b/packages/examples/src/examples/special-property-names.ts @@ -0,0 +1,133 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + numericKeys: { + type: 'object', + title: 'Object with purely numeric property names', + properties: { + '15': { + type: 'string', + title: 'Property "15"', + }, + '42': { + type: 'string', + title: 'Property "42"', + }, + }, + }, + 'property[0]': { + type: 'string', + title: 'Top-level property containing brackets: "property[0]"', + }, + 'nested[0]': { + type: 'object', + title: 'Object property whose name contains brackets: "nested[0]"', + properties: { + value: { + type: 'string', + title: 'Inner value', + }, + }, + }, + 'dashed-key': { + type: 'object', + title: 'Mixed: dashed parent with numeric and dashed children', + properties: { + '15': { + type: 'string', + title: 'Numeric child "15"', + }, + 'non-numeric-key': { + type: 'string', + title: 'Dashed sibling', + }, + }, + }, + }, +}; + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Group', + label: 'Numeric property names', + elements: [ + { + type: 'Control', + scope: '#/properties/numericKeys/properties/15', + }, + { + type: 'Control', + scope: '#/properties/numericKeys/properties/42', + }, + ], + }, + { + type: 'Group', + label: 'Property names containing brackets', + elements: [ + { + type: 'Control', + scope: '#/properties/property[0]', + }, + { + type: 'Control', + scope: '#/properties/nested[0]/properties/value', + }, + ], + }, + { + type: 'Group', + label: 'Mixed numeric and dashed property names', + elements: [ + { + type: 'Control', + scope: '#/properties/dashed-key/properties/15', + }, + { + type: 'Control', + scope: '#/properties/dashed-key/properties/non-numeric-key', + }, + ], + }, + ], +}; + +export const data = {}; + +registerExamples([ + { + name: 'special-property-names', + label: 'Special property names (numeric and bracket characters)', + data, + schema, + uischema, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index 9bd232955f..cf75ae737f 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -77,6 +77,7 @@ import * as readonly from './examples/readonly'; import * as rule from './examples/rule'; import * as ruleInheritance from './examples/ruleInheritance'; import * as scope from './examples/scope'; +import * as specialPropertyNames from './examples/special-property-names'; import * as string from './examples/string'; import * as stringArray from './examples/stringArray'; import * as text from './examples/text'; @@ -145,6 +146,7 @@ export { rule, ruleInheritance, scope, + specialPropertyNames, stepper, steppershownav, string, From 5545814c1cdade496756df633018feb20042affc Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Tue, 21 Jul 2026 15:36:39 +0000 Subject: [PATCH 2/5] core: harden setDataAt/unsetDataAt against edge cases Improvements on top of the lodash/fp/set replacement: - Store "__proto__" segments as own properties via defineProperty and traverse own properties only, so such keys neither corrupt the container's prototype nor get dropped. Clone containers key-by-key because downleveled object spreads assign instead of define. - Read the updater's old data with resolveData instead of lodash get, so reads use the same literal path semantics as writes. - Fall back to lodash's index heuristic when creating missing containers without schema type information. - Match lodash's isIndex semantics with a strict index regex instead of Number() coercion. - Avoid strict-mode TypeErrors when unsetting non-index array properties (e.g. "length") and return the same reference when there is nothing to unset. - Treat an empty path as addressing the root, and replace non-object root data instead of spreading it. Also documents the path semantics change in MIGRATION.md and adds unit tests for setDataAt/unsetDataAt. --- MIGRATION.md | 10 ++ packages/core/src/reducers/core.ts | 4 +- packages/core/src/util/setData.ts | 157 ++++++++++++-------- packages/core/test/reducers/core.test.ts | 35 +++++ packages/core/test/util/setData.test.ts | 176 +++++++++++++++++++++++ 5 files changed, 320 insertions(+), 62 deletions(-) create mode 100644 packages/core/test/util/setData.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 3a87ad1532..93027572f7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -40,6 +40,16 @@ return this.t(label, label) as string; This does not affect the Composition API where `Translator` is accessed directly from a `ComputedRef`. +### Data update paths treat all segments literally + +Data updates (e.g. dispatched `update` actions) previously wrote to the form data via lodash's `set`/`unset`, which interpret bracket notation and array indices in paths. +This corrupted data for property names that look like lodash path syntax, for example numeric property names like `"15"` were turned into array indices and names containing brackets like `"prop[0]"` were split up (see [#2397](https://github.com/eclipsesource/jsonforms/issues/2397) and [#2102](https://github.com/eclipsesource/jsonforms/issues/2102)). + +Updates now use the new `setDataAt`/`unsetDataAt` utilities of `@jsonforms/core`, which split paths on `.` and treat every segment as a literal property name, matching how JSON Forms resolves values for display. +When a missing intermediate container is created, the JSON Schema decides whether it becomes an array or an object; without schema type information, a numeric follow-up segment creates an array, as before. + +If you dispatch update actions yourself, make sure to use dot-separated paths (e.g. `update('list.0.name', ...)`) instead of lodash bracket syntax (e.g. `update('list[0].name', ...)`), which is no longer interpreted. + ### Angular support now targets Angular 20 to 22 When using JSON Forms 3.8, your Angular application now needs to target Angular 20, 21 or 22. diff --git a/packages/core/src/reducers/core.ts b/packages/core/src/reducers/core.ts index 3c6900803a..940c9d3f14 100644 --- a/packages/core/src/reducers/core.ts +++ b/packages/core/src/reducers/core.ts @@ -24,8 +24,8 @@ */ import cloneDeep from 'lodash/cloneDeep'; -import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; +import { resolveData } from '../util/resolvers'; import { setDataAt, unsetDataAt } from '../util/setData'; import { CoreActions, @@ -237,7 +237,7 @@ export const coreReducer: Reducer = ( errors, }; } else { - const oldData: any = get(state.data, action.path); + const oldData: any = resolveData(state.data, action.path); const newData = action.updater(cloneDeep(oldData)); let newState: any; if (newData !== undefined) { diff --git a/packages/core/src/util/setData.ts b/packages/core/src/util/setData.ts index 9c85aa68ac..de19f09bbb 100644 --- a/packages/core/src/util/setData.ts +++ b/packages/core/src/util/setData.ts @@ -26,10 +26,17 @@ import type { JsonSchema } from '../models'; import { encode } from './path'; import { resolveSchema } from './resolvers'; -import { hasType } from './util'; +import { deriveTypes, hasType } from './util'; const splitPath = (path: string): string[] => path.split('.'); +/** + * Whether the segment addresses an array element, i.e. is a canonical + * non-negative integer without leading zeros. + */ +const isIndexSegment = (segment: string): boolean => + /^(?:0|[1-9]\d*)$/.test(segment); + /** * Walks one step in the schema along the given data path segment, so we can * tell whether a missing intermediate container should be created as an @@ -51,21 +58,64 @@ const stepSchema = ( return resolveSchema(schema, pointer, rootSchema); }; +const assign = (container: any, segment: string, value: any): any => { + if (segment === '__proto__') { + // A plain assignment would overwrite the container's prototype instead + // of creating an own property. + Object.defineProperty(container, segment, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + } else { + container[segment] = value; + } + return container; +}; + const cloneContainer = (data: any): any => { if (Array.isArray(data)) { return [...data]; } - return { ...(data ?? {}) }; + if (typeof data === 'object' && data !== null) { + // Not a spread because downleveled object spreads assign instead of + // defining properties, which mishandles own "__proto__" properties. + const clone: { [key: string]: any } = {}; + for (const key of Object.keys(data)) { + assign(clone, key, data[key]); + } + return clone; + } + return {}; }; -const assign = (container: any, segment: string, value: any): any => { - if (Array.isArray(container)) { - const index = Number(segment); - container[Number.isInteger(index) ? index : (segment as any)] = value; - } else { - container[segment] = value; +/** + * Looks up the own property `segment` of `data`, ignoring inherited + * properties like `__proto__`, mirroring the semantics of `resolveData`. + */ +const ownPropertyValue = (data: any, segment: string): any => + data != null && Object.prototype.hasOwnProperty.call(data, segment) + ? data[segment] + : undefined; + +/** + * Determines the container to create for a missing child. The schema takes + * precedence; without any schema type information the next path segment + * decides, mirroring the behavior of lodash's `set`. + */ +const createInitialContainer = ( + childSchema: JsonSchema | undefined, + nextSegment: string +): any => { + const types = childSchema ? deriveTypes(childSchema) : []; + if (types.includes('array')) { + return []; } - return container; + if (types.length > 0) { + return {}; + } + return isIndexSegment(nextSegment) ? [] : {}; }; /** @@ -74,7 +124,10 @@ const assign = (container: any, segment: string, value: any): any => { * Numeric path segments and segments containing bracket notation are treated * as plain object property names. The optional `rootSchema` is consulted when * a new intermediate container has to be created, so that arrays are still - * created where the schema declares an array type. + * created where the schema declares an array type. Without schema type + * information, a canonical numeric follow-up segment creates an array. + * + * An empty `path` addresses the root, i.e. `value` itself is returned. */ export const setDataAt = ( data: any, @@ -82,11 +135,10 @@ export const setDataAt = ( value: any, rootSchema?: JsonSchema ): any => { - const segments = splitPath(path); - if (segments.length === 0) { + if (path === '') { return value; } - return doSet(data, segments, 0, value, rootSchema, rootSchema); + return doSet(data, splitPath(path), 0, value, rootSchema, rootSchema); }; const doSet = ( @@ -98,39 +150,26 @@ const doSet = ( rootSchema: JsonSchema | undefined ): any => { const segment = segments[index]; - const childSchema = stepSchema(currentSchema, segment, rootSchema); const container = cloneContainer(data); if (index === segments.length - 1) { return assign(container, segment, value); } - const existingChild = data?.[segment]; - let nextValue; - if ( - existingChild !== undefined && - existingChild !== null && - typeof existingChild === 'object' - ) { - nextValue = doSet( - existingChild, - segments, - index + 1, - value, - childSchema, - rootSchema - ); - } else { - const initial = hasType(childSchema, 'array') ? [] : {}; - nextValue = doSet( - initial, - segments, - index + 1, - value, - childSchema, - rootSchema - ); - } + const childSchema = stepSchema(currentSchema, segment, rootSchema); + const existingChild = ownPropertyValue(data, segment); + const child = + existingChild !== null && typeof existingChild === 'object' + ? existingChild + : createInitialContainer(childSchema, segments[index + 1]); + const nextValue = doSet( + child, + segments, + index + 1, + value, + childSchema, + rootSchema + ); return assign(container, segment, nextValue); }; @@ -139,49 +178,47 @@ const doSet = ( * * Numeric path segments and bracket notation in segments are treated as * plain object property names, mirroring the semantics of {@link setDataAt}. + * Unsetting an array element leaves a hole, i.e. the array is not compacted. + * If there is nothing to unset at `path`, `data` is returned unchanged. */ export const unsetDataAt = (data: any, path: string): any => { - const segments = splitPath(path); - if (segments.length === 0) { + if (path === '') { return data; } - return doUnset(data, segments, 0); + return doUnset(data, splitPath(path), 0); }; const doUnset = (data: any, segments: string[], index: number): any => { - if (data === undefined || data === null) { + if (data === null || typeof data !== 'object') { return data; } const segment = segments[index]; if (index === segments.length - 1) { + if (!Object.prototype.hasOwnProperty.call(data, segment)) { + return data; + } if (Array.isArray(data)) { - const container = [...data]; - const numericIndex = Number(segment); - if (Number.isInteger(numericIndex)) { - delete container[numericIndex]; + // Non-index own properties of arrays (e.g. `length`) are not form data + // and deleting them could throw in strict mode. + if (!isIndexSegment(segment)) { + return data; } + const container = [...data]; + delete container[Number(segment)]; return container; } - if (!Object.prototype.hasOwnProperty.call(data, segment)) { - return data; - } - const container: { [key: string]: any } = { ...data }; + const container = cloneContainer(data); delete container[segment]; return container; } - const existingChild = data[segment]; - if ( - existingChild === undefined || - existingChild === null || - typeof existingChild !== 'object' - ) { + const existingChild = ownPropertyValue(data, segment); + if (existingChild === null || typeof existingChild !== 'object') { return data; } const nextValue = doUnset(existingChild, segments, index + 1); if (nextValue === existingChild) { return data; } - const container = cloneContainer(data); - return assign(container, segment, nextValue); + return assign(cloneContainer(data), segment, nextValue); }; diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index 3f03756abc..1df87b84b1 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -860,6 +860,41 @@ test('core reducer - update - unset works for numeric and bracket-containing key }); }); +test('core reducer - update - updater receives the current value for special property names', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + 'test[0]': { type: 'string' }, + 'group-key': { + type: 'object', + properties: { + '15': { type: 'string' }, + }, + }, + }, + }; + + const before: JsonFormsCore = { + data: { 'test[0]': 'a', 'group-key': { '15': 'x' } }, + schema, + uischema: { type: 'Label' }, + errors: [], + validator: new Ajv().compile(schema), + }; + + const afterBracket = coreReducer( + before, + update('test[0]', (old) => old + '!') + ); + t.is((afterBracket.data as any)['test[0]'], 'a!'); + + const afterNumeric = coreReducer( + afterBracket, + update('group-key.15', (old) => old + '!') + ); + t.is((afterNumeric.data as any)['group-key']['15'], 'x!'); +}); + test('core reducer - updateErrors - should update errors with empty list', (t) => { const before: JsonFormsCore = { data: {}, diff --git a/packages/core/test/util/setData.test.ts b/packages/core/test/util/setData.test.ts new file mode 100644 index 0000000000..65572bf045 --- /dev/null +++ b/packages/core/test/util/setData.test.ts @@ -0,0 +1,176 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import test from 'ava'; +import type { JsonSchema } from '../../src'; +import { setDataAt, unsetDataAt } from '../../src/util/setData'; + +test('setDataAt - empty path replaces the root', (t) => { + t.deepEqual(setDataAt({ a: 1 }, '', { b: 2 }), { b: 2 }); +}); + +test('unsetDataAt - empty path returns the data unchanged', (t) => { + const data = { a: 1 }; + t.is(unsetDataAt(data, ''), data); +}); + +test('setDataAt - numeric key creates an object when the schema declares one', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + parent: { + type: 'object', + properties: { + '15': { type: 'string' }, + }, + }, + }, + }; + t.deepEqual(setDataAt({}, 'parent.15', 'v', schema), { + parent: { '15': 'v' }, + }); +}); + +test('setDataAt - numeric follow-up segment creates an array when no schema information is available', (t) => { + // mirrors the behavior of lodash's set for backward compatibility + t.deepEqual(setDataAt({}, 'a.0.b', 'v'), { a: [{ b: 'v' }] }); + t.deepEqual(setDataAt({}, 'a.0.b', 'v', {}), { a: [{ b: 'v' }] }); +}); + +test('setDataAt - non-canonical numeric segments do not create arrays', (t) => { + t.deepEqual(setDataAt({}, 'a.05.b', 'v'), { a: { '05': { b: 'v' } } }); + t.deepEqual(setDataAt({}, 'a.1e3.b', 'v'), { a: { '1e3': { b: 'v' } } }); +}); + +test('setDataAt - schema type information takes precedence over the numeric segment heuristic', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + parent: { + type: 'object', + properties: { + '0': { + type: 'object', + properties: { b: { type: 'string' } }, + }, + }, + }, + }, + }; + t.deepEqual(setDataAt({}, 'parent.0.b', 'v', schema), { + parent: { '0': { b: 'v' } }, + }); +}); + +test('setDataAt - creates a schema-declared array behind a $ref', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + list: { $ref: '#/definitions/list' }, + }, + definitions: { + list: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + }; + t.deepEqual(setDataAt({}, 'list.0.name', 'Alice', schema), { + list: [{ name: 'Alice' }], + }); +}); + +test('setDataAt - "__proto__" is stored as an own property without touching the prototype', (t) => { + const result = setDataAt({}, '__proto__', 'value'); + t.true(Object.prototype.hasOwnProperty.call(result, '__proto__')); + t.is(result['__proto__'], 'value'); + t.is(Object.getPrototypeOf(result), Object.prototype); +}); + +test('setDataAt - nested "__proto__" path does not pollute Object.prototype', (t) => { + const result = setDataAt({}, '__proto__.polluted', true); + t.true(Object.prototype.hasOwnProperty.call(result, '__proto__')); + t.deepEqual(result['__proto__'], { polluted: true }); + t.is(Object.getPrototypeOf(result), Object.prototype); + t.is(({} as any).polluted, undefined); + t.false('polluted' in {}); +}); + +test('setDataAt - "constructor" and "prototype" are treated as plain property names', (t) => { + const result = setDataAt({}, 'constructor.x', 1); + t.deepEqual(result, { constructor: { x: 1 } }); + t.is(typeof ({} as any).constructor, 'function'); + const result2 = setDataAt({}, 'prototype.y', 2); + t.deepEqual(result2, { prototype: { y: 2 } }); +}); + +test('setDataAt - non-object root data is replaced by an object', (t) => { + t.deepEqual(setDataAt(null, 'a', 1), { a: 1 }); + t.deepEqual(setDataAt('hello', 'a', 1), { a: 1 }); + t.deepEqual(setDataAt(42, 'a', 1), { a: 1 }); +}); + +test('unsetDataAt - removing an array element leaves a hole and keeps the length', (t) => { + const result = unsetDataAt({ a: [1, 2, 3] }, 'a.1'); + t.is(result.a.length, 3); + t.false(Object.prototype.hasOwnProperty.call(result.a, '1')); + t.is(result.a[0], 1); + t.is(result.a[2], 3); +}); + +test('unsetDataAt - removes a property inside an array item and keeps the array intact', (t) => { + const result = unsetDataAt( + { items: [{ name: 'a' }, { name: 'b' }] }, + 'items.1.name' + ); + t.true(Array.isArray(result.items)); + t.deepEqual(result, { items: [{ name: 'a' }, {}] }); +}); + +test('unsetDataAt - returns the same reference when there is nothing to unset', (t) => { + const data = { a: { b: 1 }, list: [1, 2] }; + t.is(unsetDataAt(data, 'x.y'), data); + t.is(unsetDataAt(data, 'a.c'), data); + t.is(unsetDataAt(data, 'list.5'), data); +}); + +test('unsetDataAt - non-index segments on arrays are ignored', (t) => { + const data = { list: [1, 2] }; + t.is(unsetDataAt(data, 'list.length'), data); + t.is(unsetDataAt(data, 'list.foo'), data); +}); + +test('unsetDataAt - removes own "__proto__" properties without touching the prototype', (t) => { + const data = JSON.parse('{"__proto__": {"x": 1}, "keep": true}'); + const result = unsetDataAt(data, '__proto__.x'); + t.deepEqual(result['__proto__'], {}); + t.true(result.keep); + t.is(Object.getPrototypeOf(result), Object.prototype); + // without an own "__proto__" property nothing is unset + const plain = { keep: true }; + t.is(unsetDataAt(plain, '__proto__.x'), plain); +}); From 2a4907d9579a3ebeec19b20a21c91b3f7b711047 Mon Sep 17 00:00:00 2001 From: Darius Lesch Date: Thu, 30 Jul 2026 22:32:41 +0200 Subject: [PATCH 3/5] Added `## Migration to JSON Forms 3.9` heading Added heading "Migration to JSON Forms 3.9" and moved "Data update paths treat all segments literally" from "Mifration to JSON Forms 3.8" to "Migration to JSON FOrms 3.9". This resolves the change request comment for `MIGRATION.md` file in PR #2585. --- MIGRATION.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 93027572f7..7b8be6dfe6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,17 @@ # Migration guide +## Migration to JSON Forms 3.9 + +### Data update paths treat all segments literally + +Data updates (e.g. dispatched `update` actions) previously wrote to the form data via lodash's `set`/`unset`, which interpret bracket notation and array indices in paths. +This corrupted data for property names that look like lodash path syntax, for example numeric property names like `"15"` were turned into array indices and names containing brackets like `"prop[0]"` were split up (see [#2397](https://github.com/eclipsesource/jsonforms/issues/2397) and [#2102](https://github.com/eclipsesource/jsonforms/issues/2102)). + +Updates now use the new `setDataAt`/`unsetDataAt` utilities of `@jsonforms/core`, which split paths on `.` and treat every segment as a literal property name, matching how JSON Forms resolves values for display. +When a missing intermediate container is created, the JSON Schema decides whether it becomes an array or an object; without schema type information, a numeric follow-up segment creates an array, as before. + +If you dispatch update actions yourself, make sure to use dot-separated paths (e.g. `update('list.0.name', ...)`) instead of lodash bracket syntax (e.g. `update('list[0].name', ...)`), which is no longer interpreted. + ## Migrating to JSON Forms 3.8 ### `Translator` type changed from overloaded signatures to a generic conditional type @@ -40,16 +52,6 @@ return this.t(label, label) as string; This does not affect the Composition API where `Translator` is accessed directly from a `ComputedRef`. -### Data update paths treat all segments literally - -Data updates (e.g. dispatched `update` actions) previously wrote to the form data via lodash's `set`/`unset`, which interpret bracket notation and array indices in paths. -This corrupted data for property names that look like lodash path syntax, for example numeric property names like `"15"` were turned into array indices and names containing brackets like `"prop[0]"` were split up (see [#2397](https://github.com/eclipsesource/jsonforms/issues/2397) and [#2102](https://github.com/eclipsesource/jsonforms/issues/2102)). - -Updates now use the new `setDataAt`/`unsetDataAt` utilities of `@jsonforms/core`, which split paths on `.` and treat every segment as a literal property name, matching how JSON Forms resolves values for display. -When a missing intermediate container is created, the JSON Schema decides whether it becomes an array or an object; without schema type information, a numeric follow-up segment creates an array, as before. - -If you dispatch update actions yourself, make sure to use dot-separated paths (e.g. `update('list.0.name', ...)`) instead of lodash bracket syntax (e.g. `update('list[0].name', ...)`), which is no longer interpreted. - ### Angular support now targets Angular 20 to 22 When using JSON Forms 3.8, your Angular application now needs to target Angular 20, 21 or 22. From 50538936c18943562338b9f23020e502228d4dc4 Mon Sep 17 00:00:00 2001 From: Darius Lesch Date: Thu, 30 Jul 2026 22:44:05 +0200 Subject: [PATCH 4/5] Strict equality checks inside `ownPropertyValue` Added strict equality checks (`!== null && !== undefined`) inside `ownPropertyValue`. This resolves comment in PR #2585 regarding use of loose inequality. --- packages/core/src/util/setData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/util/setData.ts b/packages/core/src/util/setData.ts index de19f09bbb..59f898f15f 100644 --- a/packages/core/src/util/setData.ts +++ b/packages/core/src/util/setData.ts @@ -95,7 +95,7 @@ const cloneContainer = (data: any): any => { * properties like `__proto__`, mirroring the semantics of `resolveData`. */ const ownPropertyValue = (data: any, segment: string): any => - data != null && Object.prototype.hasOwnProperty.call(data, segment) + data !== null && data !== undefined && Object.prototype.hasOwnProperty.call(data, segment) ? data[segment] : undefined; From da9df958d563a95dfb8e50a1e9074e15758f7d9c Mon Sep 17 00:00:00 2001 From: Darius Lesch Date: Fri, 31 Jul 2026 10:51:06 +0200 Subject: [PATCH 5/5] Resolve linting issue --- packages/core/src/util/setData.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/util/setData.ts b/packages/core/src/util/setData.ts index 59f898f15f..5b8abdfdd4 100644 --- a/packages/core/src/util/setData.ts +++ b/packages/core/src/util/setData.ts @@ -95,7 +95,9 @@ const cloneContainer = (data: any): any => { * properties like `__proto__`, mirroring the semantics of `resolveData`. */ const ownPropertyValue = (data: any, segment: string): any => - data !== null && data !== undefined && Object.prototype.hasOwnProperty.call(data, segment) + data !== null && + data !== undefined && + Object.prototype.hasOwnProperty.call(data, segment) ? data[segment] : undefined;