-
Notifications
You must be signed in to change notification settings - Fork 451
chore: replace lodash with native utilities #8189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jherr
wants to merge
3
commits into
main
Choose a base branch
from
chore/replace-lodash-with-native
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+300
−14
Open
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /** | ||
| * Checks whether a value is effectively empty. | ||
| * | ||
| * A value is considered empty when it is `null`, `undefined`, or an object | ||
| * with no own enumerable keys. Intended as a minimal stand-in for the handful | ||
| * of `lodash/isEmpty` call sites that only ever receive plain objects or | ||
| * nullish values (e.g. API response payloads that may be absent). | ||
| * | ||
| * @param obj - The object to check, or a nullish value. | ||
| * @returns `true` if the value is nullish or has no own enumerable keys. | ||
| */ | ||
| export const isEmpty = (obj: object | null | undefined): boolean => obj == null || Object.keys(obj).length === 0 | ||
|
|
||
| /** | ||
| * Returns a new object containing only the specified keys from the source. | ||
| * | ||
| * Mirrors the shape of `lodash/pick` but is intentionally loosely typed so it | ||
| * can accept string keys that are not declared on the source type. This is | ||
| * pragmatic for CLI output filtering of API responses whose generated types | ||
| * sometimes omit fields the server actually returns. | ||
| * | ||
| * Keys that are not present on the source are silently skipped. | ||
| * | ||
| * @param obj - The source object to pick properties from. | ||
| * @param keys - The property names to include in the result. | ||
| * @returns A new object with only the requested keys that exist on the source. | ||
| */ | ||
| export const pick = <T extends object>(obj: T, keys: readonly string[]): Partial<T> => | ||
| Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, (obj as Record<string, unknown>)[k]])) as Partial<T> | ||
|
|
||
| /** | ||
| * Recursively merges the properties of `source` into a copy of `target`. | ||
| * | ||
| * Only plain (non-array) objects are merged recursively; arrays and primitive | ||
| * values from `source` overwrite the corresponding value in `target`. Neither | ||
| * argument is mutated. Behavior is intentionally narrow: this is not a full | ||
| * replacement for `lodash/merge`, only enough to cover the existing call sites | ||
| * that merge shallow-nested config objects. | ||
| * | ||
| * @param target - The base object to merge into. `undefined` is treated as `{}`. | ||
| * @param source - The object whose properties take precedence over `target`. | ||
| * @returns A new object containing the combined properties. | ||
| */ | ||
| export function deepMerge<T extends Record<string, unknown>>(target: T | undefined, source: Record<string, unknown>): T { | ||
| const result: Record<string, unknown> = { ...(target ?? {}) } | ||
| for (const key of Object.keys(source)) { | ||
| const targetVal = result[key] | ||
| const sourceVal = source[key] | ||
| if ( | ||
| targetVal != null && | ||
| sourceVal != null && | ||
| typeof targetVal === 'object' && | ||
| typeof sourceVal === 'object' && | ||
| !Array.isArray(targetVal) && | ||
| !Array.isArray(sourceVal) | ||
| ) { | ||
| result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>) | ||
| } else { | ||
| result[key] = sourceVal | ||
| } | ||
| } | ||
| return result as T | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| /** | ||
| * Wraps a function so that it runs at most once per `intervalMs` milliseconds. | ||
| * | ||
| * Uses leading-edge invocation: the first call runs immediately, and any calls | ||
| * that arrive within `intervalMs` of the previous invocation are dropped. | ||
| * There is no trailing call -- this is intentional for fire-and-forget | ||
| * side-effect callers (e.g. rate-limited activity pings) where losing the | ||
| * final invocation is acceptable. | ||
| * | ||
| * @param fn - The function to throttle. | ||
| * @param intervalMs - Minimum time between invocations, in milliseconds. | ||
| * @returns A throttled wrapper around `fn` with the same arguments signature. | ||
| */ | ||
| export function throttle<Args extends unknown[]>( | ||
| fn: (...args: Args) => void, | ||
| intervalMs: number, | ||
| ): (...args: Args) => void { | ||
| let lastCall = 0 | ||
| return (...args: Args) => { | ||
| const now = Date.now() | ||
| if (now - lastCall >= intervalMs) { | ||
| lastCall = now | ||
| fn(...args) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+1
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prettier The CI Format job reports formatting issues. Please run 🧰 Tools🪛 GitHub Actions: Format[warning] 1-1: Prettier --check reported formatting/style issues in this file. 🤖 Prompt for AI Agents |
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' | ||
|
|
||
| import { deepMerge, isEmpty, pick, throttle } from '../../../src/utils/object-utilities.js' | ||
|
|
||
| describe('isEmpty', () => { | ||
| test('returns true for null', () => { | ||
| expect(isEmpty(null)).toBe(true) | ||
| }) | ||
|
|
||
| test('returns true for undefined', () => { | ||
| expect(isEmpty(undefined)).toBe(true) | ||
| }) | ||
|
|
||
| test('returns true for an empty object', () => { | ||
| expect(isEmpty({})).toBe(true) | ||
| }) | ||
|
|
||
| test('returns false for an object with properties', () => { | ||
| expect(isEmpty({ a: 1 })).toBe(false) | ||
| }) | ||
|
|
||
| test('returns false for an object with falsy values', () => { | ||
| expect(isEmpty({ a: null })).toBe(false) | ||
| expect(isEmpty({ a: undefined })).toBe(false) | ||
| expect(isEmpty({ a: 0 })).toBe(false) | ||
| expect(isEmpty({ a: '' })).toBe(false) | ||
| }) | ||
|
|
||
| test('returns true for an empty array', () => { | ||
| expect(isEmpty([])).toBe(true) | ||
| }) | ||
|
|
||
| test('returns false for a non-empty array', () => { | ||
| expect(isEmpty([1])).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('pick', () => { | ||
| test('returns a new object containing only the requested keys', () => { | ||
| const source = { a: 1, b: 2, c: 3 } | ||
| expect(pick(source, ['a', 'c'])).toEqual({ a: 1, c: 3 }) | ||
| }) | ||
|
|
||
| test('skips keys that are not present on the source', () => { | ||
| const source = { a: 1, b: 2 } | ||
| expect(pick(source, ['a', 'missing'])).toEqual({ a: 1 }) | ||
| }) | ||
|
|
||
| test('returns an empty object when no keys are requested', () => { | ||
| expect(pick({ a: 1 }, [])).toEqual({}) | ||
| }) | ||
|
|
||
| test('returns an empty object when no requested keys exist on the source', () => { | ||
| expect(pick({ a: 1 }, ['b', 'c'])).toEqual({}) | ||
| }) | ||
|
|
||
| test('does not mutate the source object', () => { | ||
| const source = { a: 1, b: 2 } | ||
| pick(source, ['a']) | ||
| expect(source).toEqual({ a: 1, b: 2 }) | ||
| }) | ||
|
|
||
| test('preserves falsy values', () => { | ||
| const source = { a: 0, b: false, c: null, d: '', e: 1 } | ||
| expect(pick(source, ['a', 'b', 'c', 'd'])).toEqual({ a: 0, b: false, c: null, d: '' }) | ||
| }) | ||
|
|
||
| test('accepts extra string keys not declared on the source type', () => { | ||
| const source: { id: string } = { id: 'abc' } | ||
| const sourceWithExtras = { ...source, undeclared: 'extra' } as { id: string } | ||
| expect(pick(sourceWithExtras, ['id', 'undeclared'])).toEqual({ id: 'abc', undeclared: 'extra' }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('deepMerge', () => { | ||
| test('treats undefined target as an empty object', () => { | ||
| expect(deepMerge<Record<string, unknown>>(undefined, { a: 1 })).toEqual({ a: 1 }) | ||
| }) | ||
|
|
||
| test('merges top-level keys from the source into the target', () => { | ||
| expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }) | ||
| }) | ||
|
|
||
| test('source values overwrite target values at the same key', () => { | ||
| expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }) | ||
| }) | ||
|
|
||
| test('recursively merges nested plain objects', () => { | ||
| const target = { auth: { token: 'old', provider: 'github' } } | ||
| const source = { auth: { token: 'new' } } | ||
| expect(deepMerge(target, source)).toEqual({ auth: { token: 'new', provider: 'github' } }) | ||
| }) | ||
|
|
||
| test('overwrites arrays instead of merging them', () => { | ||
| const target = { list: [1, 2, 3] } | ||
| const source = { list: [4] } | ||
| expect(deepMerge(target, source)).toEqual({ list: [4] }) | ||
| }) | ||
|
|
||
| test('overwrites target values with null or undefined from source', () => { | ||
| const target = { a: 1, b: 2 } | ||
| const source = { a: null, b: undefined } | ||
| expect(deepMerge(target, source)).toEqual({ a: null, b: undefined }) | ||
| }) | ||
|
|
||
| test('does not mutate the target object', () => { | ||
| const target = { auth: { token: 'old' } } | ||
| deepMerge(target, { auth: { token: 'new' } }) | ||
| expect(target).toEqual({ auth: { token: 'old' } }) | ||
| }) | ||
|
|
||
| test('does not mutate the source object', () => { | ||
| const source = { auth: { token: 'new' } } | ||
| deepMerge({ auth: { token: 'old' } }, source) | ||
| expect(source).toEqual({ auth: { token: 'new' } }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('throttle', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| test('invokes the underlying function immediately on first call', () => { | ||
| const fn = vi.fn() | ||
| const throttled = throttle(fn, 100) | ||
|
|
||
| throttled() | ||
|
|
||
| expect(fn).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| test('drops calls that arrive within the throttle interval', () => { | ||
| const fn = vi.fn() | ||
| const throttled = throttle(fn, 100) | ||
|
|
||
| throttled() | ||
| vi.advanceTimersByTime(50) | ||
| throttled() | ||
| throttled() | ||
|
|
||
| expect(fn).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| test('allows a subsequent call after the interval has elapsed', () => { | ||
| const fn = vi.fn() | ||
| const throttled = throttle(fn, 100) | ||
|
|
||
| throttled() | ||
| vi.advanceTimersByTime(100) | ||
| throttled() | ||
|
|
||
| expect(fn).toHaveBeenCalledTimes(2) | ||
| }) | ||
|
|
||
| test('forwards all arguments to the underlying function', () => { | ||
| const fn = vi.fn() | ||
| const throttled = throttle(fn, 100) | ||
|
|
||
| throttled('a', 1, { x: true }) | ||
|
|
||
| expect(fn).toHaveBeenCalledWith('a', 1, { x: true }) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.