From 44105cc29217b04ae7bddb1ced4e8afc9e00b653 Mon Sep 17 00:00:00 2001 From: Sarath Francis Date: Fri, 19 Jun 2026 00:47:42 -0400 Subject: [PATCH] fix(compat/toPath): keep empty segments for consecutive and trailing dots toPath dropped empty path segments produced by consecutive dots or a trailing dot, so 'a..b' became ['a', 'b'] instead of lodash's ['a', '', 'b'] and 'a.' became ['a'] instead of ['a', '']. Because get/set/has/unset/invokeMap parse string paths through toPath, an empty-string key in the middle of a path ('a..b' on { a: { '': ... } }) was unreachable. Emit the empty segment when a dot is directly followed by another dot or the end of the string, and let the leading dot fall through to the main loop so it is still treated as a separator. --- src/compat/array/invokeMap.spec.ts | 2 +- src/compat/object/get.spec.ts | 4 ++++ src/compat/util/toPath.spec.ts | 12 ++++++++++++ src/compat/util/toPath.ts | 11 +++++++++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/compat/array/invokeMap.spec.ts b/src/compat/array/invokeMap.spec.ts index 03d2ccde7f..f6fba8d84d 100644 --- a/src/compat/array/invokeMap.spec.ts +++ b/src/compat/array/invokeMap.spec.ts @@ -162,7 +162,7 @@ describe('invokeMap', () => { }; const aEmptyDotC = invokeMap([objectWithEmptyPart], 'a..c'); - expect(aEmptyDotC[0]).toBeUndefined(); + expect(aEmptyDotC[0]).toBe(objectWithEmptyPart.a['']); }); it('should match the type of lodash', () => { diff --git a/src/compat/object/get.spec.ts b/src/compat/object/get.spec.ts index 37a3e798a3..abbcef5776 100644 --- a/src/compat/object/get.spec.ts +++ b/src/compat/object/get.spec.ts @@ -164,4 +164,8 @@ describe('get', () => { const object = { '-0': undefined }; expect(get(object, Object(-0), 'defaultValue')).toBe('defaultValue'); }); + + it('should read empty-string keys produced by consecutive dots', () => { + expect(get({ a: { '': { b: 1 } } }, 'a..b')).toBe(1); + }); }); diff --git a/src/compat/util/toPath.spec.ts b/src/compat/util/toPath.spec.ts index 94b7d9b5f2..8c94ba4317 100644 --- a/src/compat/util/toPath.spec.ts +++ b/src/compat/util/toPath.spec.ts @@ -48,6 +48,18 @@ describe('toPath function', () => { expect(result).toEqual(['a', '', 'b']); }); + it('keeps empty segments for consecutive dots', () => { + expect(toPath('a..b')).toEqual(['a', '', 'b']); + expect(toPath('a...b')).toEqual(['a', '', '', 'b']); + expect(toPath('..a')).toEqual(['', '', 'a']); + expect(toPath('a.b..c.d')).toEqual(['a', 'b', '', 'c', 'd']); + }); + + it('keeps a trailing empty segment for a trailing dot', () => { + expect(toPath('a.')).toEqual(['a', '']); + expect(toPath('a.b.')).toEqual(['a', 'b', '']); + }); + it('should match the type of lodash', () => { expectTypeOf(toPath).toEqualTypeOf(); }); diff --git a/src/compat/util/toPath.ts b/src/compat/util/toPath.ts index 9f48546360..9442fdaab9 100644 --- a/src/compat/util/toPath.ts +++ b/src/compat/util/toPath.ts @@ -40,10 +40,10 @@ export function toPath(deepKey: any): string[] { let quoteChar = ''; let bracket = false; - // Leading dot + // Leading dot. Record the empty segment that precedes it, but leave the dot for the + // main loop so it is still treated as a separator (e.g. '..a' -> ['', '', 'a']). if (deepKey.charCodeAt(0) === 46) { result.push(''); - index++; } while (index < length) { @@ -85,6 +85,13 @@ export function toPath(deepKey: any): string[] { result.push(key); key = ''; } + // A dot that is directly followed by another dot or the end of the string + // separates an empty segment, matching lodash (e.g. 'a..b' -> ['a', '', 'b'], + // 'a.' -> ['a', '']). + const next = deepKey[index + 1]; + if (next === undefined || next === '.') { + result.push(''); + } } else { key += char; }