Drop lodash from @pi/ui

The three functions it used (isEqual, some with an object as criterion
and capitalize) live in util/common/compare.ts with the same behaviour,
so apps no longer pull a CommonJS library that Node's ESM loader cannot
resolve by path. TaskStream's initial bundle goes down to 1.88 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 06:52:28 +02:00
parent c6ca5eb6ab
commit 4a33c9ade5
12 changed files with 247 additions and 216 deletions

View File

@@ -195,12 +195,11 @@ Detalles a tener en cuenta:
ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart`
es la primera versión que realmente renderiza barras.
**`lodash` se importa por función y con extensión** (`lodash/isEqual.js`,
`lodash/some.js`, `lodash/capitalize.js`), nunca `import _ from 'lodash'`:
lodash es CommonJS y el import completo mete la librería entera en el bundle de
quien use un select. La extensión hace falta porque lodash no declara
`exports`: un empaquetador resuelve `lodash/isEqual`, pero Node en ESM (Vitest,
SSR) no, y la app falla al cargar `@pi/ui`.
**Sin lodash.** Las tres funciones que se usaban (`isEqual`, `some` con un
objeto como criterio y `capitalize`) están en `util/common/compare.ts`, con la
misma semántica. lodash es CommonJS y sin mapa de `exports`: entero pesaba en el
bundle de quien usara un select, y por funciones (`lodash/isEqual`) Node en ESM
(Vitest, SSR) no lo resolvía.
**El loader de servidor de traducciones ya no está en el barrel.**
`util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`;

View File

@@ -4,7 +4,7 @@
"lib": {
"entryFile": "src/index.ts"
},
"allowedNonPeerDependencies": ["lodash", "luxon", "ngx-infinite-scroll"],
"allowedNonPeerDependencies": ["luxon", "ngx-infinite-scroll"],
"assets": [
{
"glob": "**/*.scss",

View File

@@ -21,7 +21,6 @@
"chart.js": "^4.0.0"
},
"dependencies": {
"lodash": "^4.17.0",
"luxon": "^3.0.0",
"ngx-infinite-scroll": "^22.0.0"
},

View File

@@ -10,7 +10,7 @@ import { ComponentFamilyClass, theming } from '../../../styles';
import { Date } from '../../../util/models';
import { PiBaseChipComponent } from '../base-chip.component';
import { DateTime } from 'luxon';
import capitalize from 'lodash/capitalize.js';
import { capitalize } from '../../../util/common/compare';
@Component({
selector: 'pi-chip-date',

View File

@@ -9,7 +9,7 @@ import {
inject,
} from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import isEqual from 'lodash/isEqual.js';
import { isEqual } from '../../../util/common/compare';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import { Device } from '../../../util/device-configs';

View File

@@ -11,7 +11,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import some from 'lodash/some.js';
import { isMatch } from '../../../util/common/compare';
import { MatCheckboxChange, MatCheckbox } from '@angular/material/checkbox';
import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -114,7 +114,9 @@ export class PiSelectMultipleComponent
get selectedOptions(): Array<any> {
const value = this.value();
if (!value) return [];
return this.options().filter((option) => some(value, option));
return this.options().filter((option) =>
value.some((selected: unknown) => isMatch(selected, option)),
);
}
/**

View File

@@ -11,7 +11,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import isEqual from 'lodash/isEqual.js';
import { isEqual } from '../../../util/common/compare';
import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiDisabledDirective } from '../../../util/status/directives/status.directive';

View File

@@ -13,7 +13,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import isEqual from 'lodash/isEqual.js';
import { isEqual } from '../../../util/common/compare';
import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiDisabledDirective } from '../../../util/status/directives/status.directive';

View File

@@ -0,0 +1,39 @@
import { capitalize, isEqual, isMatch } from './compare';
describe('compare', () => {
it('isEqual compara en profundidad', () => {
expect(
isEqual({ id: 1, tags: ['a', 'b'] }, { tags: ['a', 'b'], id: 1 }),
).toBe(true);
expect(isEqual({ id: 1 }, { id: 1, extra: true })).toBe(false);
expect(isEqual([1, [2, 3]], [1, [2, 3]])).toBe(true);
expect(isEqual([1, 2], [2, 1])).toBe(false);
expect(isEqual(new Date(0), new Date(0))).toBe(true);
expect(isEqual(NaN, NaN)).toBe(true);
expect(isEqual(null, undefined)).toBe(false);
expect(isEqual('1', 1)).toBe(false);
expect(isEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false);
});
it('isMatch acepta lo que contiene el criterio, como `_.some(lista, objeto)`', () => {
const option = { id: 2, name: 'Dos' };
expect(
[
{ id: 1, name: 'Uno' },
{ id: 2, name: 'Dos', extra: 1 },
].some((v) => isMatch(v, option)),
).toBe(true);
expect([{ id: 2 }].some((v) => isMatch(v, option))).toBe(false);
expect(isMatch({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true);
expect(isMatch({ tags: ['a', 'b', 'c'] }, { tags: ['c', 'a'] })).toBe(true);
expect(isMatch({ tags: ['a'] }, { tags: ['z'] })).toBe(false);
expect(isMatch('x', 'x')).toBe(true);
expect(isMatch(null, { id: 1 })).toBe(false);
});
it('capitalize pone la primera en mayúscula y el resto en minúscula', () => {
expect(capitalize('lun')).toBe('Lun');
expect(capitalize('HOY')).toBe('Hoy');
expect(capitalize('')).toBe('');
});
});

View File

@@ -0,0 +1,66 @@
/**
* Las tres utilidades de lodash que usaba la librería (`isEqual`, `some` con
* un objeto como criterio y `capitalize`), con la misma semántica en los
* casos que se dan aquí: valores JSON, fechas, arrays y objetos planos. Sin
* lodash, `@pi/ui` no arrastra una librería CommonJS al bundle de nadie.
*/
/** Igualdad profunda: `isEqual({ a: [1] }, { a: [1] })` es `true`. */
export function isEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (a instanceof Date && b instanceof Date)
return a.getTime() === b.getTime();
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((item, i) => isEqual(item, b[i]))
);
}
if (!isPlainObject(a) || !isPlainObject(b)) return false;
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => hasOwn(b, key) && isEqual(a[key], b[key]))
);
}
/**
* Si `value` contiene lo que pide `source`: cada propiedad de `source` está
* en `value` con un valor que a su vez la contiene (y el resto da igual).
* Es lo que hace `_.some(lista, objeto)` con cada elemento.
*/
export function isMatch(value: unknown, source: unknown): boolean {
if (isPlainObject(source)) {
return (
isPlainObject(value) &&
Object.keys(source).every(
(key) => hasOwn(value, key) && isMatch(value[key], source[key]),
)
);
}
if (Array.isArray(source)) {
// Como lodash: cada elemento pedido tiene que estar en el array.
return (
Array.isArray(value) &&
source.every((wanted) => value.some((item) => isMatch(item, wanted)))
);
}
return isEqual(value, source);
}
/** Primera letra en mayúscula y el resto en minúscula: «lun» → «Lun». */
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function hasOwn(object: object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(object, key);
}

328
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,6 @@
"@ngx-translate/http-loader": "^18.0.0",
"chart.js": "^4.5.1",
"express": "^4.21.2",
"lodash": "^4.18.1",
"luxon": "^3.7.2",
"ngx-infinite-scroll": "^22.0.0",
"rxjs": "~7.8.0",
@@ -71,7 +70,6 @@
"@swc/helpers": "0.5.18",
"@types/express": "^4.17.21",
"@types/jest": "~30.0.0",
"@types/lodash": "^4.17.24",
"@types/luxon": "^3.7.2",
"@types/node": "^24.0.0",
"@types/supertest": "^6.0.3",