From 0f1e0291b74c98a0f0206bb5e3f41d9ea21f2582 Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 18:34:51 +0100 Subject: [PATCH 1/6] refactor: use custom merge,mergeWith,defaultsDeep --- packages/svelte-stores/src/lib/fetchStore.ts | 2 +- packages/svelte-stores/src/lib/graphStore.ts | 2 +- packages/svelte-table/src/lib/actions.ts | 2 +- packages/tailwind/src/lib/utils.ts | 2 +- packages/utils/src/lib/index.ts | 1 + packages/utils/src/lib/locale.ts | 2 +- packages/utils/src/lib/mergeWith.test.ts | 216 +++++++++++++++++++ packages/utils/src/lib/mergeWith.ts | 77 +++++++ packages/utils/src/lib/object.ts | 5 +- 9 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 packages/utils/src/lib/mergeWith.test.ts create mode 100644 packages/utils/src/lib/mergeWith.ts diff --git a/packages/svelte-stores/src/lib/fetchStore.ts b/packages/svelte-stores/src/lib/fetchStore.ts index 295986b..2aa754a 100644 --- a/packages/svelte-stores/src/lib/fetchStore.ts +++ b/packages/svelte-stores/src/lib/fetchStore.ts @@ -1,7 +1,7 @@ import { getContext, setContext } from 'svelte'; import { get, writable } from 'svelte/store'; import type { Writable } from 'svelte/store'; -import { merge } from 'lodash-es'; +import { merge } from '@layerstack/utils'; type BodyMethods = 'arrayBuffer' | 'blob' | 'formData' | 'json' | 'text'; diff --git a/packages/svelte-stores/src/lib/graphStore.ts b/packages/svelte-stores/src/lib/graphStore.ts index 69e2bb5..4e8cac3 100644 --- a/packages/svelte-stores/src/lib/graphStore.ts +++ b/packages/svelte-stores/src/lib/graphStore.ts @@ -1,6 +1,6 @@ import { setContext, getContext } from 'svelte'; import { writable } from 'svelte/store'; -import { merge } from 'lodash-es'; +import { merge } from '@layerstack/utils'; import { parse, stringify } from '@layerstack/utils'; diff --git a/packages/svelte-table/src/lib/actions.ts b/packages/svelte-table/src/lib/actions.ts index b7b475c..97fc720 100644 --- a/packages/svelte-table/src/lib/actions.ts +++ b/packages/svelte-table/src/lib/actions.ts @@ -1,5 +1,5 @@ import type { Action } from 'svelte/action'; -import { merge } from 'lodash-es'; +import { merge } from '@layerstack/utils'; import { extent, max, min } from 'd3-array'; import type { tableOrderStore } from './stores.js'; diff --git a/packages/tailwind/src/lib/utils.ts b/packages/tailwind/src/lib/utils.ts index 6cd5564..57798e7 100644 --- a/packages/tailwind/src/lib/utils.ts +++ b/packages/tailwind/src/lib/utils.ts @@ -1,7 +1,7 @@ import clsx, { type ClassValue } from 'clsx'; import { extendTailwindMerge } from 'tailwind-merge'; import { range } from 'd3-array'; -import { mergeWith } from 'lodash-es'; +import { mergeWith } from '@layerstack/utils'; /** * Wrapper around `tailwind-merge` and `clsx` diff --git a/packages/utils/src/lib/index.ts b/packages/utils/src/lib/index.ts index 31f6a15..57deade 100644 --- a/packages/utils/src/lib/index.ts +++ b/packages/utils/src/lib/index.ts @@ -34,6 +34,7 @@ export * from './json.js'; export * from './logger.js'; export { round, clamp, randomInteger } from './number.js'; export { isEmptyObject, isLiteralObject, omit, pick } from './object.js'; +export { mergeWith, merge, defaultsDeep } from './mergeWith.js'; export * from './promise.js'; export * from './sort.js'; export * from './string.js'; diff --git a/packages/utils/src/lib/locale.ts b/packages/utils/src/lib/locale.ts index b50a0e7..c1dcd9d 100644 --- a/packages/utils/src/lib/locale.ts +++ b/packages/utils/src/lib/locale.ts @@ -1,5 +1,5 @@ import { entries, fromEntries, type Prettify } from './typeHelpers.js'; -import { defaultsDeep } from 'lodash-es'; +import { defaultsDeep } from './mergeWith.js'; import { derived, writable, type Readable, type Writable } from 'svelte/store'; import { DateToken, diff --git a/packages/utils/src/lib/mergeWith.test.ts b/packages/utils/src/lib/mergeWith.test.ts new file mode 100644 index 0000000..ecfbfcc --- /dev/null +++ b/packages/utils/src/lib/mergeWith.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; + +import { merge, mergeWith, defaultsDeep } from './mergeWith.js'; + +describe('merge', () => { + it('merges flat objects', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + const result = merge(target, source); + + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + expect(result).toBe(target); + }); + + it('deep merges nested objects', () => { + const target = { a: { x: 1, y: 2 }, b: 1 }; + const source = { a: { y: 3, z: 4 }, c: 2 }; + + const result = merge(target, source); + + expect(result).toEqual({ + a: { x: 1, y: 3, z: 4 }, + b: 1, + c: 2, + }); + }); + + it('merges arrays by index with plain objects', () => { + const target = { items: [{ a: 1 }, { b: 2 }] }; + const source = { items: [{ a: 10 }, { c: 3 }] }; + + const result = merge(target, source); + + expect(result).toEqual({ + items: [{ a: 10 }, { b: 2, c: 3 }], + }); + }); + + it('replaces array items that are not plain objects', () => { + const target = { a: [1, 2, 3] }; + const source = { a: [4, 5] }; + + const result = merge(target, source); + + expect(result).toEqual({ a: [4, 5, 3] }); + }); + + it('handles multiple sources', () => { + const target = { a: 1 }; + const source1 = { b: 2 }; + const source2 = { c: 3 }; + + const result = merge(target, source1, source2); + + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('creates nested objects if target property is not an object', () => { + const target = { a: 1 } as any; + const source = { a: { nested: true } }; + + const result = merge(target, source); + + expect(result).toEqual({ a: { nested: true } }); + }); + + it('creates arrays if target property is not an array', () => { + const target = { a: 'string' } as any; + const source = { a: [1, 2] }; + + const result = merge(target, source); + + expect(result).toEqual({ a: [1, 2] }); + }); + + it('works with multiple levels of nesting', () => { + const target = { + level1: { + level2: { + level3: { a: 1 }, + }, + }, + }; + const source = { + level1: { + level2: { + level3: { b: 2 }, + }, + }, + }; + + const result = merge(target, source); + + expect(result).toEqual({ + level1: { + level2: { + level3: { a: 1, b: 2 }, + }, + }, + }); + }); +}); + +describe('mergeWith', () => { + it('uses customizer result when not undefined', () => { + const target = { a: [1, 2], b: [3] }; + const source = { a: [4], b: [5, 6] }; + + const result = mergeWith(target, source, (tgtVal: any, srcVal: any) => { + if (Array.isArray(tgtVal) && Array.isArray(srcVal)) { + return [...tgtVal, ...srcVal]; + } + }); + + expect(result).toEqual({ + a: [1, 2, 4], + b: [3, 5, 6], + }); + }); + + it('customizer can replace arrays entirely', () => { + const target = { items: [1, 2] }; + const source = { items: [3] }; + + const result = mergeWith(target, source, (_: any, srcVal: any) => { + if (Array.isArray(srcVal)) return srcVal; + }); + + expect(result).toEqual({ items: [3] }); + }); + + it('falls back to default merge when customizer returns undefined', () => { + const target = { a: { x: 1 }, b: 2 }; + const source = { a: { y: 2 }, c: 3 }; + + const result = mergeWith(target, source, () => undefined); + + expect(result).toEqual({ a: { x: 1, y: 2 }, b: 2, c: 3 }); + }); + + it('provides correct arguments to customizer', () => { + const target = { a: 1 }; + const source = { a: 2, b: 3 }; + const calls: any[] = []; + + mergeWith(target, source, (tgtVal: any, srcVal: any, key: any) => { + calls.push({ tgtVal, srcVal, key }); + return undefined; + }); + + expect(calls).toEqual([ + { tgtVal: 1, srcVal: 2, key: 'a' }, + { tgtVal: undefined, srcVal: 3, key: 'b' }, + ]); + }); + + it('supports multiple sources with customizer', () => { + const result = mergeWith( + {}, + { a: 'one', b: 'two' }, + { b: 'THREE', c: 'four' }, + (tgt: string, src: string) => (tgt ? `${tgt} ${src}` : undefined) + ); + + expect(result).toEqual({ a: 'one', b: 'two THREE', c: 'four' }); + }); + + it('skips null/undefined sources', () => { + const result = mergeWith({}, { a: 1 }, null, undefined, { b: 2 }, () => undefined); + + expect(result).toEqual({ a: 1, b: 2 }); + }); +}); + +describe('defaultsDeep', () => { + it('fills in undefined values from defaults', () => { + const result = defaultsDeep({ a: 1 }, { a: 2, b: 3 }); + expect(result).toEqual({ a: 1, b: 3 }); + }); + + it('deeply fills in nested defaults', () => { + const result = defaultsDeep({ a: { b: 2 } }, { a: { b: 1, c: 3 } }); + expect(result).toEqual({ a: { b: 2, c: 3 } }); + }); + + it('preserves existing values at all levels', () => { + const result = defaultsDeep( + { level1: { level2: { existing: 'keep' } } }, + { level1: { level2: { existing: 'replace', added: 'new' }, other: 'value' } } + ); + expect(result).toEqual({ + level1: { level2: { existing: 'keep', added: 'new' }, other: 'value' }, + }); + }); + + it('handles multiple default sources', () => { + const result = defaultsDeep({ a: 1 }, { b: 2 }, { c: 3, a: 100 }); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('does not override with undefined source values', () => { + const result = defaultsDeep({ a: 1 }, { a: undefined, b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('keeps arrays from target', () => { + const result = defaultsDeep({ items: [1, 2] }, { items: [3, 4, 5] }); + expect(result).toEqual({ items: [1, 2] }); + }); + + it('fills in arrays when target has undefined', () => { + const result = defaultsDeep({}, { items: [1, 2] }); + expect(result).toEqual({ items: [1, 2] }); + }); +}); diff --git a/packages/utils/src/lib/mergeWith.ts b/packages/utils/src/lib/mergeWith.ts new file mode 100644 index 0000000..54f4743 --- /dev/null +++ b/packages/utils/src/lib/mergeWith.ts @@ -0,0 +1,77 @@ +function isObject(value: any): value is object { + return typeof value === 'object' && value !== null; +} + +function isPlainObject(value: any): value is Record { + if (typeof value !== 'object' || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} + +type Customizer = (objValue: any, srcValue: any, key: string) => any; + +function mergeOne(target: T, source: object, customizer?: Customizer): T { + if (!isObject(target) || !isObject(source)) return target; + + for (const key in source) { + const srcVal = (source as any)[key]; + const tgtVal = (target as any)[key]; + + if (customizer) { + const custom = customizer(tgtVal, srcVal, key); + if (custom !== undefined) { + (target as any)[key] = custom; + continue; + } + } + + if (Array.isArray(srcVal)) { + if (!Array.isArray(tgtVal)) (target as any)[key] = []; + srcVal.forEach((item, i) => { + if (isPlainObject(item) && isPlainObject((target as any)[key][i])) { + (target as any)[key][i] = mergeOne((target as any)[key][i], item, customizer); + } else { + (target as any)[key][i] = item; + } + }); + } else if (isPlainObject(srcVal)) { + if (!isPlainObject(tgtVal)) (target as any)[key] = {}; + mergeOne((target as any)[key], srcVal, customizer); + } else { + (target as any)[key] = srcVal; + } + } + + return target; +} + +export function mergeWith(target: any, ...args: any[]): any { + const last = args[args.length - 1]; + const hasCustomizer = typeof last === 'function'; + const customizer = hasCustomizer ? (last as Customizer) : undefined; + const sources = hasCustomizer ? args.slice(0, -1) : args; + + for (const source of sources) { + if (source) mergeOne(target, source, customizer); + } + return target; +} + +export function merge(target: any, ...sources: any[]): any { + for (const source of sources) { + if (source) mergeOne(target, source); + } + return target; +} + +export function defaultsDeep(target: object, ...sources: object[]): T { + return mergeWith(target, ...sources, (objValue: any, srcValue: any) => { + if (objValue !== undefined) { + if (isPlainObject(objValue) && isPlainObject(srcValue)) { + return undefined; + } + return objValue; + } + return undefined; + }); +} diff --git a/packages/utils/src/lib/object.ts b/packages/utils/src/lib/object.ts index 6df031f..303eba1 100644 --- a/packages/utils/src/lib/object.ts +++ b/packages/utils/src/lib/object.ts @@ -1,4 +1,5 @@ -import { get, mergeWith } from 'lodash-es'; +import { get } from 'lodash-es'; +import { mergeWith } from './mergeWith.js'; import { entries, fromEntries, keys } from './typeHelpers.js'; import { toCamelCase } from './string.js'; @@ -77,7 +78,7 @@ function flatten(items: T[][]): T[] { * @returns */ export function merge(object: TObject, source: TSource) { - return mergeWith(object, source, (objValue, srcValue) => { + return mergeWith(object, source, (objValue: any, srcValue: any) => { if (Array.isArray(srcValue)) { // Overwrite instead of merging by index with objValue (like standard lodash `merge` does) return srcValue; From 57af369fd6aa5ce9f60a712af856d56637328612 Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 18:38:52 +0100 Subject: [PATCH 2/6] refactor: use custom get --- packages/svelte-table/src/lib/utils.ts | 4 +- packages/utils/src/lib/get.test.ts | 82 ++++++++++++++++++++++++++ packages/utils/src/lib/get.ts | 39 ++++++++++++ packages/utils/src/lib/index.ts | 1 + packages/utils/src/lib/object.ts | 2 +- packages/utils/src/lib/rollup.ts | 2 +- 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 packages/utils/src/lib/get.test.ts create mode 100644 packages/utils/src/lib/get.ts diff --git a/packages/svelte-table/src/lib/utils.ts b/packages/svelte-table/src/lib/utils.ts index b158697..64f32f9 100644 --- a/packages/svelte-table/src/lib/utils.ts +++ b/packages/svelte-table/src/lib/utils.ts @@ -1,6 +1,4 @@ -import { get } from 'lodash-es'; - -import { PeriodType, parseDate } from '@layerstack/utils'; +import { PeriodType, parseDate, get } from '@layerstack/utils'; import type { ColumnDef } from './types.js'; diff --git a/packages/utils/src/lib/get.test.ts b/packages/utils/src/lib/get.test.ts new file mode 100644 index 0000000..8cbccfc --- /dev/null +++ b/packages/utils/src/lib/get.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; + +import { get } from './get.js'; + +describe('get', () => { + it('returns value at string path', () => { + const obj = { a: { b: { c: 3 } } }; + expect(get(obj, 'a.b.c')).toBe(3); + }); + + it('returns value at array path', () => { + const obj = { a: { b: { c: 3 } } }; + expect(get(obj, ['a', 'b', 'c'])).toBe(3); + }); + + it('returns value at symbol path', () => { + const sym = Symbol('key'); + const obj = { [sym]: 'value' }; + expect(get(obj, sym)).toBe('value'); + }); + + it('returns defaultValue when path does not exist', () => { + const obj = { a: { b: 1 } }; + expect(get(obj, 'a.c', 'default')).toBe('default'); + expect(get(obj, 'x.y.z', 42)).toBe(42); + }); + + it('returns defaultValue for null/undefined object', () => { + expect(get(null, 'a.b', 'default')).toBe('default'); + expect(get(undefined, 'a.b', 'default')).toBe('default'); + }); + + it('returns undefined when path does not exist and no default provided', () => { + const obj = { a: 1 }; + expect(get(obj, 'b')).toBeUndefined(); + expect(get(obj, 'a.b.c')).toBeUndefined(); + }); + + it('handles array indices in string path', () => { + const obj = { a: [{ b: 1 }, { b: 2 }] }; + expect(get(obj, 'a.0.b')).toBe(1); + expect(get(obj, 'a.1.b')).toBe(2); + }); + + it('handles array indices in array path', () => { + const obj = { a: [{ b: 1 }, { b: 2 }] }; + expect(get(obj, ['a', 0, 'b'])).toBe(1); + expect(get(obj, ['a', 1, 'b'])).toBe(2); + }); + + it('returns value when path leads to falsy value', () => { + const obj = { a: { b: 0, c: false, d: '', e: null } }; + expect(get(obj, 'a.b', 'default')).toBe(0); + expect(get(obj, 'a.c', 'default')).toBe(false); + expect(get(obj, 'a.d', 'default')).toBe(''); + expect(get(obj, 'a.e', 'default')).toBe(null); + }); + + it('returns defaultValue when intermediate path is undefined', () => { + const obj = { a: undefined }; + expect(get(obj, 'a.b', 'default')).toBe('default'); + }); + + it('does not mutate the original object', () => { + const obj = { a: { b: 1 } }; + const original = JSON.stringify(obj); + get(obj, 'a.b'); + get(obj, 'a.c', 'default'); + expect(JSON.stringify(obj)).toBe(original); + }); + + it('handles empty string path', () => { + const obj = { '': 'empty-key-value' }; + expect(get(obj, '')).toBe('empty-key-value'); + }); + + it('handles deeply nested paths', () => { + const obj = { a: { b: { c: { d: { e: { f: 'deep' } } } } } }; + expect(get(obj, 'a.b.c.d.e.f')).toBe('deep'); + expect(get(obj, ['a', 'b', 'c', 'd', 'e', 'f'])).toBe('deep'); + }); +}); diff --git a/packages/utils/src/lib/get.ts b/packages/utils/src/lib/get.ts new file mode 100644 index 0000000..5cf5d2f --- /dev/null +++ b/packages/utils/src/lib/get.ts @@ -0,0 +1,39 @@ +/** + * See: https://github.com/angus-c/just/blob/d8c5dd18941062d8db7e9310ecc8f53fd607df54/packages/object-safe-get/index.mjs#L33C1-L61C2 + */ +export function get( + obj: any, + propsArg: string | symbol | (string | number | symbol)[], + defaultValue?: T +): T { + if (!obj) { + return defaultValue as T; + } + + let props: (string | number | symbol)[]; + + if (Array.isArray(propsArg)) { + props = propsArg.slice(0); + } else if (typeof propsArg === 'string') { + props = propsArg.split('.'); + } else if (typeof propsArg === 'symbol') { + props = [propsArg]; + } else { + throw new Error('props arg must be an array, a string or a symbol'); + } + + let result: any = obj; + + while (props.length) { + const prop = props.shift()!; + if (!result) { + return defaultValue as T; + } + result = result[prop]; + if (result === undefined) { + return defaultValue as T; + } + } + + return result; +} diff --git a/packages/utils/src/lib/index.ts b/packages/utils/src/lib/index.ts index 57deade..678b7d8 100644 --- a/packages/utils/src/lib/index.ts +++ b/packages/utils/src/lib/index.ts @@ -33,6 +33,7 @@ export { export * from './json.js'; export * from './logger.js'; export { round, clamp, randomInteger } from './number.js'; +export { get } from './get.js'; export { isEmptyObject, isLiteralObject, omit, pick } from './object.js'; export { mergeWith, merge, defaultsDeep } from './mergeWith.js'; export * from './promise.js'; diff --git a/packages/utils/src/lib/object.ts b/packages/utils/src/lib/object.ts index 303eba1..85d1d30 100644 --- a/packages/utils/src/lib/object.ts +++ b/packages/utils/src/lib/object.ts @@ -1,4 +1,4 @@ -import { get } from 'lodash-es'; +import { get } from './get.js'; import { mergeWith } from './mergeWith.js'; import { entries, fromEntries, keys } from './typeHelpers.js'; import { toCamelCase } from './string.js'; diff --git a/packages/utils/src/lib/rollup.ts b/packages/utils/src/lib/rollup.ts index b35eeeb..96c3ad5 100644 --- a/packages/utils/src/lib/rollup.ts +++ b/packages/utils/src/lib/rollup.ts @@ -1,5 +1,5 @@ import { rollup } from 'd3-array'; -import { get } from 'lodash-es'; +import { get } from './get.js'; export default function ( data: T[], From 9ab46bd0c0087a67bc949d4e0c4b9c5cd778e634 Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 18:40:51 +0100 Subject: [PATCH 3/6] refactor: use custom set --- packages/svelte-stores/src/lib/formStore.ts | 2 +- packages/utils/src/lib/index.ts | 1 + packages/utils/src/lib/set.test.ts | 124 ++++++++++++++++++++ packages/utils/src/lib/set.ts | 50 ++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/lib/set.test.ts create mode 100644 packages/utils/src/lib/set.ts diff --git a/packages/svelte-stores/src/lib/formStore.ts b/packages/svelte-stores/src/lib/formStore.ts index 80db64f..a24a139 100644 --- a/packages/svelte-stores/src/lib/formStore.ts +++ b/packages/svelte-stores/src/lib/formStore.ts @@ -10,7 +10,7 @@ import { type Patch, } from 'immer'; import type { Schema } from 'zod'; -import { set } from 'lodash-es'; +import { set } from '@layerstack/utils'; // Needed for finishDraft() patches/inverseChanges - https://immerjs.github.io/immer/patches enablePatches(); diff --git a/packages/utils/src/lib/index.ts b/packages/utils/src/lib/index.ts index 678b7d8..66d6187 100644 --- a/packages/utils/src/lib/index.ts +++ b/packages/utils/src/lib/index.ts @@ -34,6 +34,7 @@ export * from './json.js'; export * from './logger.js'; export { round, clamp, randomInteger } from './number.js'; export { get } from './get.js'; +export { set } from './set.js'; export { isEmptyObject, isLiteralObject, omit, pick } from './object.js'; export { mergeWith, merge, defaultsDeep } from './mergeWith.js'; export * from './promise.js'; diff --git a/packages/utils/src/lib/set.test.ts b/packages/utils/src/lib/set.test.ts new file mode 100644 index 0000000..8823a22 --- /dev/null +++ b/packages/utils/src/lib/set.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; + +import { set } from './set.js'; + +describe('set', () => { + it('sets value at string path', () => { + const obj = { a: { b: { c: 1 } } }; + const result = set(obj, 'a.b.c', 3); + expect(result).toBe(true); + expect(obj.a.b.c).toBe(3); + }); + + it('sets value at array path', () => { + const obj = { a: { b: { c: 1 } } }; + const result = set(obj, ['a', 'b', 'c'], 3); + expect(result).toBe(true); + expect(obj.a.b.c).toBe(3); + }); + + it('sets value at symbol path', () => { + const sym = Symbol('key'); + const obj: Record = {}; + const result = set(obj, sym, 'value'); + expect(result).toBe(true); + expect(obj[sym]).toBe('value'); + }); + + it('creates nested objects when path does not exist', () => { + const obj: Record = {}; + set(obj, 'a.b.c', 'new'); + expect(obj).toEqual({ a: { b: { c: 'new' } } }); + }); + + it('overwrites existing value', () => { + const obj = { a: 'old' }; + set(obj, 'a', 'new'); + expect(obj.a).toBe('new'); + }); + + it('handles array indices in string path', () => { + const obj: Record = { a: [{}, {}] }; + set(obj, 'a.0.b', 1); + set(obj, 'a.1.b', 2); + expect(obj.a[0].b).toBe(1); + expect(obj.a[1].b).toBe(2); + }); + + it('handles array indices in array path', () => { + const obj: Record = { a: [{}, {}] }; + set(obj, ['a', 0, 'b'], 1); + set(obj, ['a', 1, 'b'], 2); + expect(obj.a[0].b).toBe(1); + expect(obj.a[1].b).toBe(2); + }); + + it('returns false for empty path', () => { + const obj = { a: 1 }; + expect(set(obj, [], 'value')).toBe(false); + }); + + it('throws error for __proto__ path', () => { + const obj = {}; + expect(() => set(obj, '__proto__', {})).toThrow('setting of prototype values not supported'); + expect(() => set(obj, ['__proto__'], {})).toThrow('setting of prototype values not supported'); + }); + + it('throws error for constructor path', () => { + const obj = {}; + expect(() => set(obj, 'constructor', {})).toThrow('setting of prototype values not supported'); + }); + + it('throws error for prototype path', () => { + const obj = {}; + expect(() => set(obj, 'prototype', {})).toThrow('setting of prototype values not supported'); + }); + + it('throws error for __proto__ in nested path', () => { + const obj: Record = { a: {} }; + expect(() => set(obj, 'a.__proto__.b', 'value')).toThrow( + 'setting of prototype values not supported' + ); + }); + + it('returns false when intermediate path is not an object', () => { + const obj = { a: 'string' }; + const result = set(obj, 'a.b.c', 'value'); + expect(result).toBe(false); + }); + + it('returns false when intermediate path is null', () => { + const obj: Record = { a: null }; + const result = set(obj, 'a.b', 'value'); + expect(result).toBe(false); + }); + + it('sets value with various types', () => { + const obj: Record = {}; + set(obj, 'string', 'hello'); + set(obj, 'number', 42); + set(obj, 'boolean', true); + set(obj, 'array', [1, 2, 3]); + set(obj, 'object', { nested: true }); + set(obj, 'null', null); + + expect(obj.string).toBe('hello'); + expect(obj.number).toBe(42); + expect(obj.boolean).toBe(true); + expect(obj.array).toEqual([1, 2, 3]); + expect(obj.object).toEqual({ nested: true }); + expect(obj.null).toBe(null); + }); + + it('handles deeply nested paths', () => { + const obj: Record = {}; + set(obj, 'a.b.c.d.e.f', 'deep'); + expect(obj.a.b.c.d.e.f).toBe('deep'); + }); + + it('mutates the original object', () => { + const obj = { a: 1 }; + set(obj, 'a', 2); + expect(obj.a).toBe(2); + }); +}); diff --git a/packages/utils/src/lib/set.ts b/packages/utils/src/lib/set.ts new file mode 100644 index 0000000..953525d --- /dev/null +++ b/packages/utils/src/lib/set.ts @@ -0,0 +1,50 @@ +/** + * See: https://github.com/angus-c/just/blob/d8c5dd18941062d8db7e9310ecc8f53fd607df54/packages/object-safe-set/index.mjs#L22C2-L61C2 + */ + +function prototypeCheck(prop: string | number | symbol): void { + // coercion is intentional to catch prop values like `['__proto__']` + if (prop == '__proto__' || prop == 'constructor' || prop == 'prototype') { + throw new Error('setting of prototype values not supported'); + } +} + +export function set( + obj: Record, + propsArg: string | symbol | (string | number | symbol)[], + value: any +): boolean { + let props: (string | number | symbol)[]; + + if (Array.isArray(propsArg)) { + props = propsArg.slice(0); + } else if (typeof propsArg === 'string') { + props = propsArg.split('.'); + } else if (typeof propsArg === 'symbol') { + props = [propsArg]; + } else { + throw new Error('props arg must be an array, a string or a symbol'); + } + + const lastProp = props.pop(); + if (lastProp === undefined) { + return false; + } + + prototypeCheck(lastProp); + + let thisProp: string | number | symbol | undefined; + while ((thisProp = props.shift()) !== undefined) { + prototypeCheck(thisProp); + if (typeof obj[thisProp] === 'undefined') { + obj[thisProp] = {}; + } + obj = obj[thisProp]; + if (!obj || typeof obj !== 'object') { + return false; + } + } + + obj[lastProp] = value; + return true; +} From 71cd3a5eb02723a01aaf0600880bef444c81380b Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 18:44:57 +0100 Subject: [PATCH 4/6] refactor: use custom isEqual --- .../svelte-stores/src/lib/queryParamsStore.ts | 3 +- packages/utils/src/lib/index.ts | 1 + packages/utils/src/lib/isEqual.test.ts | 46 +++++++++++++++++++ packages/utils/src/lib/isEqual.ts | 40 ++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 packages/utils/src/lib/isEqual.test.ts create mode 100644 packages/utils/src/lib/isEqual.ts diff --git a/packages/svelte-stores/src/lib/queryParamsStore.ts b/packages/svelte-stores/src/lib/queryParamsStore.ts index 9589ba9..bbd1435 100644 --- a/packages/svelte-stores/src/lib/queryParamsStore.ts +++ b/packages/svelte-stores/src/lib/queryParamsStore.ts @@ -1,10 +1,9 @@ import { derived, get, type Readable } from 'svelte/store'; import type { Page } from '@sveltejs/kit'; -import { isEqual } from 'lodash-es'; import * as Serialize from '@layerstack/utils/serialize'; import rollup from '@layerstack/utils/rollup'; -import { entries, type ValueOf } from '@layerstack/utils'; +import { entries, isEqual, type ValueOf } from '@layerstack/utils'; // Matches $app/navigation's goto without dependency - https://kit.svelte.dev/docs/modules#$app-navigation-goto type Goto = (url: string | URL, opts?: any) => any; diff --git a/packages/utils/src/lib/index.ts b/packages/utils/src/lib/index.ts index 66d6187..6e082ba 100644 --- a/packages/utils/src/lib/index.ts +++ b/packages/utils/src/lib/index.ts @@ -35,6 +35,7 @@ export * from './logger.js'; export { round, clamp, randomInteger } from './number.js'; export { get } from './get.js'; export { set } from './set.js'; +export { isEqual } from './isEqual.js'; export { isEmptyObject, isLiteralObject, omit, pick } from './object.js'; export { mergeWith, merge, defaultsDeep } from './mergeWith.js'; export * from './promise.js'; diff --git a/packages/utils/src/lib/isEqual.test.ts b/packages/utils/src/lib/isEqual.test.ts new file mode 100644 index 0000000..02dd55d --- /dev/null +++ b/packages/utils/src/lib/isEqual.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; + +import { isEqual } from './isEqual.js'; + +describe('isEqual', () => { + it('compares primitives', () => { + expect(isEqual(1, 1)).toBe(true); + expect(isEqual(1, 2)).toBe(false); + expect(isEqual('a', 'a')).toBe(true); + expect(isEqual(true, false)).toBe(false); + expect(isEqual(null, null)).toBe(true); + expect(isEqual(undefined, undefined)).toBe(true); + expect(isEqual(null, undefined)).toBe(false); + }); + + it('treats NaN as equal to NaN', () => { + expect(isEqual(Number.NaN, Number.NaN)).toBe(true); + }); + + it('compares arrays deeply', () => { + expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false); + }); + + it('compares objects deeply', () => { + expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true); + expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe(false); + }); + + it('compares dates by timestamp', () => { + expect( + isEqual(new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z')) + ).toBe(true); + expect( + isEqual(new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z')) + ).toBe(false); + }); + + it('handles circular references', () => { + const a: any = { x: 1 }; + a.self = a; + const b: any = { x: 1 }; + b.self = b; + expect(isEqual(a, b)).toBe(true); + }); +}); diff --git a/packages/utils/src/lib/isEqual.ts b/packages/utils/src/lib/isEqual.ts new file mode 100644 index 0000000..1158e4e --- /dev/null +++ b/packages/utils/src/lib/isEqual.ts @@ -0,0 +1,40 @@ +export function isEqual(a: any, b: any, seen = new WeakMap()): boolean { + if (a === b || (a !== a && b !== b)) return true; // identical or both NaN + if (a == null || b == null) return a === b; + if (typeof a !== 'object' || typeof b !== 'object') return false; + + // Circular reference handling + if (seen.has(a)) return seen.get(a) === b; + seen.set(a, b); + + // Dates + if (a instanceof Date && b instanceof Date) return +a === +b; + if (a instanceof Date || b instanceof Date) return false; + + // Maps + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (const [k, v] of a) if (!b.has(k) || !isEqual(v, b.get(k), seen)) return false; + return true; + } + if (a instanceof Map || b instanceof Map) return false; + + // Sets + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; + } + if (a instanceof Set || b instanceof Set) return false; + + // Arrays + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => isEqual(v, b[i], seen)); + } + if (Array.isArray(a) || Array.isArray(b)) return false; + + // Plain objects + const keysA = Object.keys(a); + const keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every((k) => k in b && isEqual(a[k], b[k], seen)); +} From 09b7888e642e585065e2243bc3613ad71cfb79ff Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 18:46:12 +0100 Subject: [PATCH 5/6] build(deps): drop lodash-es --- packages/svelte-stores/package.json | 1 - packages/svelte-table/package.json | 3 +-- packages/tailwind/package.json | 1 - packages/utils/package.json | 3 +-- pnpm-lock.yaml | 12 ------------ 5 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/svelte-stores/package.json b/packages/svelte-stores/package.json index 2e3dbfb..f29dfc8 100644 --- a/packages/svelte-stores/package.json +++ b/packages/svelte-stores/package.json @@ -37,7 +37,6 @@ "dependencies": { "@layerstack/utils": "workspace:*", "immer": "^10.1.1", - "lodash-es": "^4.17.21", "zod": "^3.24.3" }, "main": "./dist/index.js", diff --git a/packages/svelte-table/package.json b/packages/svelte-table/package.json index e225e88..235f41e 100644 --- a/packages/svelte-table/package.json +++ b/packages/svelte-table/package.json @@ -37,8 +37,7 @@ "dependencies": { "@layerstack/svelte-actions": "workspace:*", "@layerstack/utils": "workspace:*", - "d3-array": "^3.2.4", - "lodash-es": "^4.17.21" + "d3-array": "^3.2.4" }, "main": "./dist/index.js", "exports": { diff --git a/packages/tailwind/package.json b/packages/tailwind/package.json index a10ba9c..507f6c3 100644 --- a/packages/tailwind/package.json +++ b/packages/tailwind/package.json @@ -41,7 +41,6 @@ "@layerstack/utils": "workspace:^", "clsx": "^2.1.1", "d3-array": "^3.2.4", - "lodash-es": "^4.17.21", "tailwind-merge": "^3.2.0" }, "main": "./dist/index.js", diff --git a/packages/utils/package.json b/packages/utils/package.json index 4107474..4467155 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -38,8 +38,7 @@ "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", - "d3-time-format": "^4.1.0", - "lodash-es": "^4.17.21" + "d3-time-format": "^4.1.0" }, "main": "./dist/index.js", "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac46037..03bcb25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,9 +127,6 @@ importers: immer: specifier: ^10.1.1 version: 10.1.1 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 zod: specifier: ^3.24.3 version: 3.24.4 @@ -188,9 +185,6 @@ importers: d3-array: specifier: ^3.2.4 version: 3.2.4 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 devDependencies: '@sveltejs/package': specifier: ^2.3.11 @@ -246,9 +240,6 @@ importers: d3-array: specifier: ^3.2.4 version: 3.2.4 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 tailwind-merge: specifier: ^3.2.0 version: 3.3.0 @@ -313,9 +304,6 @@ importers: d3-time-format: specifier: ^4.1.0 version: 4.1.0 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 devDependencies: '@sveltejs/package': specifier: ^2.3.11 From 68d26ed3966932df4a180abae8916600a6a95192 Mon Sep 17 00:00:00 2001 From: es3n1n Date: Wed, 17 Dec 2025 19:11:22 +0100 Subject: [PATCH 6/6] chore: add changeset --- .changeset/dry-ends-read.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/dry-ends-read.md diff --git a/.changeset/dry-ends-read.md b/.changeset/dry-ends-read.md new file mode 100644 index 0000000..d9b6dc7 --- /dev/null +++ b/.changeset/dry-ends-read.md @@ -0,0 +1,10 @@ +--- +'@layerstack/svelte-actions': patch +'@layerstack/svelte-stores': patch +'@layerstack/svelte-state': patch +'@layerstack/svelte-table': patch +'@layerstack/tailwind': patch +'@layerstack/utils': patch +--- + +Remove lodash-es dependency