Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ and dependency-only bumps are omitted.

### Changed

- `dot-prop-immutable` (unmaintained since 2020) is no longer a
dependency: the single function the library used (`set`) is now a
small internal module with the same semantics, validated against the
original with a differential test battery. Behavior of form updates is
unchanged — dot paths with numeric indexes, creation of missing
intermediate objects, and structural immutability (untouched siblings
keep their reference identity) all work exactly as before. The only
deviation is a hardening one: `__proto__`/`constructor`-style path
segments can no longer touch any prototype (writes create plain own
properties instead of invoking inherited setters).
- The npm package is now built with Vite (Rollup) instead of Babel CLI.
The published surface is unchanged — ES modules mirroring the source
layout under `dist/`, one file per module (tree-shaking preserved),
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"@types/json-schema": "^7.0.15",
"ajv": "^6.11.0",
"classnames": "^2.2.6",
"dot-prop-immutable": "^2.1.0",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2"
},
Expand Down
12 changes: 7 additions & 5 deletions src/lib/Form/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/

import Ajv from 'ajv';
import immutable from 'dot-prop-immutable';

import setIn from './setIn';

/**
* An AJV `ErrorObject` enriched with a `field` property — a normalized,
Expand Down Expand Up @@ -301,9 +302,10 @@ export const getFieldValue = (target) => {

/**
* Returns a copy of `data` with the field paths described by `events` updated
* to their new values. Uses `dot-prop-immutable` so that only the modified
* branches of the object tree get new references; untouched siblings keep
* their identity (useful for `React.memo` / `PureComponent`).
* to their new values. Uses {@link setIn} (a local replacement for
* `dot-prop-immutable.set`) so that only the modified branches of the object
* tree get new references; untouched siblings keep their identity (useful
* for `React.memo` / `PureComponent`).
*
* @template {object} T
* @param {T} data
Expand All @@ -315,7 +317,7 @@ export const updateDataFromEvents = (data, events) => {
const eventsArray = Array.isArray(events) ? events : [events];

eventsArray.forEach((event) => {
data = immutable.set(data, event.target.name, getFieldValue(event.target));
data = setIn(data, event.target.name, getFieldValue(event.target));
});

return data;
Expand Down
151 changes: 151 additions & 0 deletions src/lib/Form/setIn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Local, dependency-free replacement for `dot-prop-immutable`'s `set()`
* (the only function of that package the library ever used). The package
* has been unmaintained since 2020, so its semantics are inlined here and
* pinned by tests (they were validated against the real
* `dot-prop-immutable@2.1.0` with a differential test battery before the
* dependency was removed).
*
* Reproduced semantics:
* - Dot-separated paths (`'user.email'`, `'items.0.label'`) — the same
* grammar as `<Field name>` and the `field` of formatted errors. A `\.`
* escapes a literal dot inside a key (`'a\\.b'` targets the key `'a.b'`).
* - Structural immutability: only the branches on the written path get new
* references (arrays cloned with `slice`, objects with spread); untouched
* siblings keep their identity — `React.memo` / `PureComponent` friendly.
* - Missing intermediates are created as plain objects — even for numeric
* segments (`set({}, 'list.1', 'x')` gives `{ list: { 1: 'x' } }`, not an
* array). Arrays are only index-addressed when they already exist.
* - On an existing array: `$end` targets the last index, indexes may carry a
* leading `+`, any other non-integer segment throws
* `Array index '…' has to be an integer`, and out-of-bounds indexes
* extend the array (holes in between).
* - Scalar intermediates are spread-cloned like objects (numbers/booleans
* collapse to `{}`, strings to char-indexed objects); a `null`
* intermediate followed by more segments throws a `TypeError`, exactly
* like the original.
* - A function `value` acts as an updater: it receives the current value at
* the path and its return value is written.
*
* One deliberate deviation, for safety: property writes go through
* `Object.defineProperty` instead of `clone[key] = value`. For regular keys
* the result is identical (own enumerable/writable/configurable data
* property), but a `'__proto__'` segment creates a plain own property
* instead of triggering the `Object.prototype.__proto__` setter (which in
* the original silently rewrote the clone's prototype). Combined with the
* fact that writes only ever target freshly created clones, no path can
* mutate a shared prototype (`__proto__`, `constructor.prototype`, …).
*/

/**
* Splits a dot-separated path into segments, honoring `\.` escapes: a
* segment ending with a lone (unescaped) backslash is merged with the next
* one, the backslash becoming a literal dot. Same algorithm as
* `dot-prop-immutable`'s `propToArray`.
*
* @param {string} path
* @returns {string[]}
*/
const splitPath = (path) => path.split('.').reduce(
(/** @type {string[]} */ segments, segment, index, list) => {
const previous = index > 0 ? list[index - 1] : undefined;
if (previous !== undefined && /(?:^|[^\\])\\$/.test(previous)) {
const merged = segments.pop() ?? '';
segments.push(`${merged.slice(0, -1)}.${segment}`);
} else {
segments.push(segment);
}
return segments;
},
[],
);

/**
* Resolves a path segment against an existing array: `$end` becomes the
* last index (0 on an empty array), integer segments (optionally
* `+`-prefixed) are parsed, anything else throws — same behavior as
* `dot-prop-immutable`'s `getArrayIndex`.
*
* @param {string} segment
* @param {readonly unknown[]} array
* @returns {number}
*/
const toArrayIndex = (segment, array) => {
if (segment === '$end') return Math.max(array.length - 1, 0);
if (!/^\+?\d+$/.test(segment)) {
throw new Error(`Array index '${segment}' has to be an integer`);
}
return parseInt(segment, 10);
};

/**
* Writes an own data property without ever invoking a setter inherited
* from the prototype chain (notably the `__proto__` accessor). This is the
* prototype-pollution guard described in the module JSDoc.
*
* @param {object} target
* @param {string | number} key
* @param {unknown} value
*/
const defineOwn = (target, key, value) => {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
};

/**
* Returns a copy of `data` with the value at `path` replaced by `value`,
* cloning only the traversed branch (see module JSDoc for the full
* semantics).
*
* @template T
* @param {T} data
* @param {string} path - Dot-separated field path (e.g. `'items.0.label'`).
* @param {unknown} value - Value to write, or an updater function receiving
* the current value at the path.
* @returns {T}
*/
const setIn = (data, path, value) => {
const segments = splitPath(path);

/**
* @param {unknown} target
* @param {number} i
* @returns {unknown}
*/
const setInRec = (target, i) => {
if (i >= segments.length) {
return typeof value === 'function' ? value(target) : value;
}

/** @type {string | number} */
let head = segments[i];
/** @type {Record<PropertyKey, unknown> | unknown[]} */
let clone;
if (Array.isArray(target)) {
head = toArrayIndex(head, target);
clone = target.slice();
} else {
// Spread-clones any non-array: objects keep their own enumerable
// properties, `null`/numbers/booleans collapse to `{}`, strings
// become char-indexed objects — same as the original's
// `Object.assign({}, obj)`.
const source = /** @type {Record<PropertyKey, unknown>} */ (target);
clone = { ...source };
}

// Reading `target[head]` throws a TypeError when `target` is null or
// undefined and segments remain — intentional parity with the
// original implementation.
const current = /** @type {Record<PropertyKey, unknown>} */ (target)[head];
defineOwn(clone, head, setInRec(current !== undefined ? current : {}, i + 1));
return clone;
};

return /** @type {T} */ (setInRec(data, 0));
};

export default setIn;
174 changes: 174 additions & 0 deletions src/lib/Form/setIn.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import setIn from './setIn';

describe('setIn(data, path, value)', () => {
describe('basic writes', () => {
it('should set a top-level property', () => {
expect(setIn({ a: 1 }, 'b', 2)).toEqual({ a: 1, b: 2 });
});

it('should overwrite an existing property', () => {
expect(setIn({ a: 1 }, 'a', 2)).toEqual({ a: 2 });
});

it('should set a deep property', () => {
expect(setIn({ user: { email: 'old' } }, 'user.email', 'new@x.fr'))
.toEqual({ user: { email: 'new@x.fr' } });
});

it('should set an empty-string key for an empty path', () => {
expect(setIn({}, '', 'v')).toEqual({ '': 'v' });
});
});

describe('array indexes', () => {
it('should set an index of an existing array and keep it an array', () => {
const result = setIn({ items: ['a', 'b'] }, 'items.1', 'z');
expect(Array.isArray(result.items)).toBe(true);
expect(result.items).toEqual(['a', 'z']);
});

it('should set a deep property inside an array item', () => {
const result = setIn({ items: [{ label: 'a' }, { label: 'b' }] }, 'items.0.label', 'z');
expect(Array.isArray(result.items)).toBe(true);
expect(result.items).toEqual([{ label: 'z' }, { label: 'b' }]);
});

it('should extend the array when the index is out of bounds', () => {
const result = setIn({ items: ['a'] }, 'items.3', 'z');
expect(Array.isArray(result.items)).toBe(true);
expect(result.items.length).toBe(4);
expect(result.items[3]).toBe('z');
});

it('should accept a leading + on an index', () => {
expect(setIn({ items: ['a', 'b'] }, 'items.+1', 'z')).toEqual({ items: ['a', 'z'] });
});

it('should resolve $end to the last index', () => {
expect(setIn({ items: [1, 2, 3] }, 'items.$end', 9)).toEqual({ items: [1, 2, 9] });
expect(setIn({ items: [] }, 'items.$end', 9)).toEqual({ items: [9] });
});

it('should throw on a non-integer segment applied to an array', () => {
expect(() => setIn({ items: ['a'] }, 'items.foo', 1))
.toThrow("Array index 'foo' has to be an integer");
});
});

describe('missing intermediates', () => {
it('should create missing intermediate objects', () => {
expect(setIn({}, 'a.b.c', 1)).toEqual({ a: { b: { c: 1 } } });
});

it('should create an object with a numeric key (not an array) on an absent target', () => {
// Documented dot-prop-immutable behavior: arrays are never created,
// only index-addressed when they already exist.
const result = setIn({}, 'list.1', 'x');
expect(Array.isArray(result.list)).toBe(false);
expect(result.list).toEqual({ 1: 'x' });
});

it('should treat an explicit undefined like a missing intermediate', () => {
expect(setIn({ a: undefined }, 'a.b', 1)).toEqual({ a: { b: 1 } });
});
});

describe('scalar and null intermediates (dot-prop-immutable parity)', () => {
it('should replace a number or boolean intermediate with an object', () => {
expect(setIn({ a: 5 }, 'a.b', 1)).toEqual({ a: { b: 1 } });
expect(setIn({ a: true }, 'a.b', 1)).toEqual({ a: { b: 1 } });
});

it('should spread a string intermediate into a char-indexed object', () => {
expect(setIn({ a: 'hi' }, 'a.b', 1)).toEqual({
a: {
0: 'h', 1: 'i', b: 1,
},
});
});

it('should throw a TypeError on a null intermediate with segments remaining', () => {
expect(() => setIn({ a: null }, 'a.b', 1)).toThrow(TypeError);
});

it('should still allow replacing a null leaf', () => {
expect(setIn({ a: null }, 'a', 1)).toEqual({ a: 1 });
});
});

describe('structural immutability', () => {
it('should not mutate the source object', () => {
const data = { user: { email: 'old' }, other: { k: 1 } };
setIn(data, 'user.email', 'new');
expect(data).toEqual({ user: { email: 'old' }, other: { k: 1 } });
});

it('should keep untouched siblings identical by reference and renew the written branch', () => {
const data = {
user: { email: 'old', address: { city: 'Paris' } },
sibling: { k: 1 },
list: [1, 2],
};
const result = setIn(data, 'user.email', 'new');

// New references along the written branch only.
expect(result).not.toBe(data);
expect(result.user).not.toBe(data.user);
// Untouched siblings keep their identity (React.memo contract).
expect(result.sibling).toBe(data.sibling);
expect(result.list).toBe(data.list);
expect(result.user.address).toBe(data.user.address);
});

it('should not mutate a source array and keep sibling items identical by reference', () => {
const data = { items: [{ label: 'a' }, { label: 'b' }] };
const result = setIn(data, 'items.0.label', 'z');

expect(data.items[0].label).toBe('a');
expect(result.items).not.toBe(data.items);
expect(result.items[0]).not.toBe(data.items[0]);
expect(result.items[1]).toBe(data.items[1]);
});
});

describe('path grammar', () => {
it('should honor \\. as an escaped literal dot inside a key', () => {
expect(setIn({}, 'a\\.b.c', 1)).toEqual({ 'a.b': { c: 1 } });
});

it('should treat a leading dot as an empty first segment', () => {
expect(setIn({}, '.a', 1)).toEqual({ '': { a: 1 } });
});
});

describe('updater function values', () => {
it('should call a function value with the current value and write its result', () => {
expect(setIn({ n: 2 }, 'n', (v) => v * 10)).toEqual({ n: 20 });
});
});

describe('dangerous keys (prototype-pollution hardening)', () => {
it('should never pollute Object.prototype via __proto__ paths', () => {
const result = setIn({}, '__proto__.polluted', true);
expect({}.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
// The written key is a plain own property, and the result's
// prototype is untouched (safer than dot-prop-immutable, which
// rewrote the clone's prototype through the __proto__ setter).
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.getOwnPropertyDescriptor(result, '__proto__').value)
.toEqual({ polluted: true });
});

it('should never pollute prototypes via constructor.prototype paths', () => {
setIn({}, 'constructor.prototype.polluted', true);
expect({}.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
});

it('should keep plain constructor keys working as regular own properties', () => {
expect(JSON.parse(JSON.stringify(setIn({}, 'constructor.x', 1))))
.toEqual({ constructor: { x: 1 } });
});
});
});
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1690,11 +1690,6 @@ dom-helpers@^3.4.0:
dependencies:
"@babel/runtime" "^7.1.2"

dot-prop-immutable@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/dot-prop-immutable/-/dot-prop-immutable-2.1.0.tgz#aa08d57b7b4c8ba1aac1b8f9b058bb3f37027af6"
integrity sha512-8IIkxeXjLkKbhbqwmqCdFzRup1wFwH4tuY7/s5ScVpzYXgjZ+lw5ocvtDG1KR9VG+/AIGgGpWBdzmq+QUFANoA==

dts-bundle-generator@^9.5.1:
version "9.5.1"
resolved "https://registry.yarnpkg.com/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz#7eac7f47a2d5b51bdaf581843e7f969b88bfc225"
Expand Down