Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
43 changes: 43 additions & 0 deletions .changeset/9204-lucide-dynamic-map-off-the-eager-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
---

Take lucide's dynamic-import map off the eager path (objectui#9204).

`lucide-react/dynamic.mjs` publishes `iconNames` as `Object.keys(dynamicIconImports)`
— a list derived at module init from a 120,683-byte import map. Four modules imported
that specifier, three of them for the names alone, so every page load paid for the map.
Beside it, `renderers/action/resolve-icon.ts` indexes lucide's `icons` record, and a
namespace object has no dead members: every icon module was already eager in the same
chunk. `DynamicIcon` was therefore `import()`-ing modules that were already loaded —
laziness that bought nothing and cost the map.

The forgiving icon vocabulary is now REBUILT from the record's own keys, plus a
generated table of the 264 names no key can produce (retired spellings such as
`alert-triangle`, and lucide's alternate digit spellings such as `arrow-down-0-1`),
each mapped to its live record key by object identity. `LazyIcon` / `getLazyIcon` /
`isLucideIconName` accept **exactly** the same 2,039 names as before — asserted in both
directions against the installed lucide by a drift test, so a lucide bump cannot narrow
what the renderer draws in silence.

Measured on one pair of console builds at `69aa9c017`, read from
`apps/console/dist/eager-closure.json`:

chunk `ui-components` 397,090 -> 353,658 gzipped -43,432
eager closure 3,180,382 -> 3,136,585 -43,797
eager / total chunks 52/528 -> 51/527

**Behaviour.** Icon resolution is now synchronous: `getLazyIcon(name)` returns lucide's
own component rather than a `DynamicIcon` wrapper, so an icon paints on first render
instead of after a chunk fetch, and there are no per-icon micro-chunks to request. The
exported API is unchanged, and unknown names still degrade to the `Database` glyph.

**New export.** `lucideIconNames()` on `@object-ui/components` returns the vocabulary as
a sorted string array — the list `app-shell`'s metadata-admin icon picker used to take
from `lucide-react/dynamic.mjs`.

**Consolidation.** `@object-ui/app-shell`'s `utils/getIcon` and the console's were
transcriptions of the same resolver, each with its own tokeniser, its own `Set` over
lucide's map and its own memo. Both are now re-exports of `getLazyIcon`: one resolver,
one vocabulary, one memo.
49 changes: 13 additions & 36 deletions apps/console/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,18 @@
/**
* Icon utilities
*
* Synchronous accessor that returns a lazy-loaded Lucide icon React
* component. Wraps lucide-react's `DynamicIcon` so we don't bloat the
* vendor bundle by statically importing the entire icon namespace.
*/

import React from 'react';
import { Database } from 'lucide-react';
import { DynamicIcon } from 'lucide-react/dynamic';

function toKebab(name: string): string {
if (name.includes('-')) return name.toLowerCase();
return name
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}

const cache = new Map<string, React.ElementType>();

/**
* Resolve a Lucide icon component by name.
* ⛔ NOT a resolver. This is a re-export of `@object-ui/components`'
* `getLazyIcon`, kept only so the call sites in this app can go on saying
* `getIcon(name)`.
*
* The result is memoised per name in the module-level `cache`, so call sites
* get a *stable* component reference across renders — nothing is created during
* render. `react-hooks/static-components` cannot see through the call, so the
* JSX sites that render the result carry a targeted disable pointing back here.
* It used to wrap `lucide-react/dynamic.mjs`'s `DynamicIcon` directly, with no
* membership check at all, which put that module's 120,683-byte import map on
* the eager path for a lookup the shared resolver already does — and did it
* against a vocabulary this copy never consulted (objectui#9204).
*
* The result is still memoised per name in the shared resolver, so call sites
* still get a stable component reference across renders; the targeted
* `react-hooks/static-components` disables at those sites point back here.
*/
export function getIcon(name?: string): React.ElementType {
if (!name) return Database;
const cached = cache.get(name);
if (cached) return cached;
const kebab = toKebab(name);
const Wrapped: React.FC<any> = (props) =>
React.createElement(DynamicIcon as any, { name: kebab, fallback: Database, ...props });
Wrapped.displayName = `LucideIcon(${name})`;
cache.set(name, Wrapped);
return Wrapped;
}

export { getLazyIcon as getIcon } from '@object-ui/components';
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"check:action-forward-parity": "node scripts/check-action-forward-parity.mjs",
"check:designer-field-key-parity": "node scripts/check-designer-field-key-parity.mjs",
"check:icon-record-names": "node scripts/check-lucide-icon-record-names.mjs",
"gen:lucide-aliases": "node scripts/gen-lucide-dynamic-name-aliases.mjs",
"check:lucide-aliases": "node scripts/gen-lucide-dynamic-name-aliases.mjs --check",
"check:phantom-deps": "node scripts/check-phantom-dependencies.mjs",
"check:unused-deps": "node scripts/check-unused-dependencies.mjs",
"check:self-import": "node scripts/check-package-self-import.mjs",
Expand Down
70 changes: 10 additions & 60 deletions packages/app-shell/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,15 @@
/**
* Icon utilities
*
* Helpers for resolving Lucide icons by name.
*
* Implementation: instead of statically importing every icon (~1500
* components, ~568 KB raw / 140 KB gz), we wrap lucide-react's built-in
* `DynamicIcon` so each icon is fetched as its own tiny chunk on first use.
*
* The exported `getIcon(name)` API stays synchronous and returns a React
* component, preserving call sites that do `const Icon = getIcon(name); <Icon />`.
*/

import React from 'react';
import { Database } from 'lucide-react';
import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs';

/** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */
function toKebab(name: string): string {
if (name.includes('-')) return name.toLowerCase();
return name
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}

// Lucide ships ~3900 icon names; storing as a Set keeps lookups O(1).
const VALID_ICON_NAMES: Set<string> = new Set(iconNames as string[]);

const cache = new Map<string, React.ElementType>();

/**
* Resolve a Lucide icon by name (kebab-case or PascalCase).
*
* Returns a React component that lazy-loads the underlying SVG icon on
* mount. Falls back to the `Database` icon (statically imported) when no
* `name` is given, or when the requested name is not a valid Lucide icon
* — server-driven metadata frequently references icons from other libraries
* (e.g. `box-open` from Font Awesome), and we silently degrade to the
* fallback rather than letting Lucide log a console error.
*
* The returned component is memoised per `name` so repeated calls with the
* same name yield the same component reference (stable for React.memo).
* ⛔ NOT a resolver. This is a re-export of `@object-ui/components`'
* `getLazyIcon`, kept only so the call sites in this package can go on saying
* `getIcon(name)`.
*
* It used to be a third transcription of the same twenty lines — its own
* `toKebab`, its own `Set(iconNames)` over `lucide-react/dynamic.mjs`, its own
* memo cache. Three copies meant three vocabularies that could drift, and each
* `Set(iconNames)` paid for lucide's 120,683-byte import map on the eager path
* (objectui#9204). One resolver, one vocabulary, one memo.
*/
export function getIcon(name?: string): React.ElementType {
if (!name) return Database;
const cached = cache.get(name);
if (cached) return cached;

const kebab = toKebab(name);
if (!VALID_ICON_NAMES.has(kebab)) {
cache.set(name, Database);
return Database;
}

const Wrapped: React.FC<any> = (props) =>
React.createElement(DynamicIcon as any, {
name: kebab,
fallback: Database,
...props,
});
Wrapped.displayName = `LucideIcon(${name})`;
cache.set(name, Wrapped);
return Wrapped;
}
export { getLazyIcon as getIcon } from '@object-ui/components';
9 changes: 6 additions & 3 deletions packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
Label,
Switch,
LazyIcon,
lucideIconNames,
toKebabIconName,
Popover,
PopoverTrigger,
Expand All @@ -43,7 +44,6 @@ import {
} from '@object-ui/components';
import type { ComponentMeta } from '@object-ui/core';
import { AlertTriangle, ChevronDown, ChevronsUpDown, ChevronUp, Eye, EyeOff, Plus, Search, Trash2 } from 'lucide-react';
import { iconNames } from 'lucide-react/dynamic.mjs';
import { toast } from 'sonner';
import { useObjectTranslation } from '@object-ui/i18n';
import { useMetadataLocale, t, tFormat } from './i18n.js';
Expand Down Expand Up @@ -1553,8 +1553,11 @@ function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledB
/* icon — searchable Lucide icon picker */
/* -------------------------------------------------------------------------- */

// Lucide ships ~1500+ kebab-case icon names; freeze once for O(1) reuse.
const LUCIDE_ICON_NAMES: readonly string[] = iconNames as string[];
// Lucide ships ~2000 kebab-case icon names. `lucideIconNames()` rebuilds that
// vocabulary from the `icons` record the bundle already carries — this module
// used to import `lucide-react/dynamic.mjs` for the list alone and dragged its
// 120,683-byte import map onto every page load with it (objectui#9204).
const LUCIDE_ICON_NAMES: readonly string[] = lucideIconNames();
const LUCIDE_ICON_SET: Set<string> = new Set(LUCIDE_ICON_NAMES);
// Cap the rendered grid — each cell mounts a lazily-loaded icon, so showing all
// ~1500 at once would fire a flood of chunk requests. The search box narrows it.
Expand Down
69 changes: 58 additions & 11 deletions packages/cli/src/__tests__/workspace-vite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,18 +194,65 @@ describe('lucide-react alias (objectui#3890)', () => {
expect(manifest.name).toBe('lucide-react');
});

it('keeps the subpath the component library imports resolvable', () => {
// The specifier is read out of the importer instead of being written here,
// so this pins the real consumer rather than a copy of it. With the entry
// file as the alias target, this rewrite produced `<entry>/<subpath>` and
// `packages/components/src/lib/lazy-icon.tsx` answered 500 once the
// platform aliases made it reachable at all.
const importer = readFileSync(join(REPO_ROOT, 'packages/components/src/lib/lazy-icon.tsx'), 'utf-8');
const match = /['"]lucide-react\/([^'"]+)['"]/.exec(importer);
expect(match, 'lazy-icon.tsx no longer imports a lucide-react subpath').not.toBeNull();

const rewritten = join(lucide as string, (match as RegExpExecArray)[1]);
/**
* Every first-party bundled source file that imports a `lucide-react/<subpath>`
* specifier, rediscovered rather than remembered. The subpath this test
* rewrites is taken from here when the repo has a consumer, so it goes on
* pinning the real one rather than a copy of it.
*/
function firstPartySubpathImporters(): { file: string; subpath: string }[] {
const found: { file: string; subpath: string }[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === 'dist') continue;
walk(full);
} else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
const match = /from ['"]lucide-react\/([^'"]+)['"]/.exec(readFileSync(full, 'utf-8'));
if (match) found.push({ file: full, subpath: match[1] });
}
}
};
for (const pkg of ['packages', 'apps']) {
const root = join(REPO_ROOT, pkg);
if (existsSync(root)) walk(root);
}
return found;
}

it('keeps a lucide subpath resolvable through the rewrite', () => {
// With the entry file as the alias target this rewrite produced
// `<entry>/<subpath>`, and `packages/components/src/lib/lazy-icon.tsx`
// answered 500 once the platform aliases made it reachable at all. The
// property is about the alias TARGET being a directory, and it holds for any
// subpath the package publishes.
//
// ⚠️ objectui#9204 removed the last first-party BUNDLED importer of one:
// `lazy-icon.tsx` read `lucide-react/dynamic.mjs` for a list of icon names
// and dragged its 120,683-byte import map onto every page load with it. The
// specifier used to be read out of that file, "so this pins the real
// consumer rather than a copy of it" — so the discovery above still looks
// for a consumer first, and only falls back to a subpath lucide's own
// manifest publishes when the repo has none. An app built on `@object-ui/*`
// may still import one, which is why the alias itself is not retired.
const importers = firstPartySubpathImporters();
const subpath = importers[0]?.subpath ?? 'dynamic.mjs';

const rewritten = join(lucide as string, subpath);
expect(existsSync(rewritten), `${rewritten} must exist for the aliased subpath to resolve`).toBe(true);

// The control that keeps the line above from passing on anything: the same
// rewrite against the package's RESOLVED ENTRY — the shape objectui#3890
// fixed — must NOT resolve. The entry is read off the manifest, so the
// contrast survives lucide moving its build output.
const manifest = JSON.parse(readFileSync(join(lucide as string, 'package.json'), 'utf-8')) as {
module?: string;
main?: string;
};
const entry = join(lucide as string, (manifest.module ?? manifest.main) as string);
expect(existsSync(entry), 'the entry file this test contrasts with must exist').toBe(true);
expect(existsSync(join(entry, subpath))).toBe(false);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import './renderers';
export { cn } from './lib/utils';
export { renderChildren, renderNodeSlot, isEmptyNodeSlot } from './lib/utils';
export { cva } from 'class-variance-authority';
export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/lazy-icon';
export { getLazyIcon, isLucideIconName, LazyIcon, lucideIconNames, toKebabIconName } from './lib/lazy-icon';

// The member-action visibility gate — "did this action DECLARE a `visible` gate
// at all?", the single definition objectui#3492 established and PR #3816 /
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect } from 'vitest';
import { icons, Database } from 'lucide-react';
import { iconNames } from 'lucide-react/dynamic.mjs';
import { getLazyIcon, isLucideIconName, lucideIconNames } from '../lazy-icon';
import {
LUCIDE_DYNAMIC_NAME_ALIASES,
LUCIDE_DERIVED_NAME_EXCLUSIONS,
} from '../lucide-dynamic-name-aliases';

/**
* The drift guard for objectui#9204.
*
* `lazy-icon.tsx` no longer imports `lucide-react/dynamic.mjs`. Its 120,683-byte
* import map was eager in the `ui-components` chunk for four modules that wanted
* only the NAMES it is keyed by, while the `icons` record those names index was
* eager beside it — two encodings of one catalogue, both paid for on every page
* load. The vocabulary is now REBUILT from the record's keys plus a generated
* table of the names no key can produce.
*
* ⚠️ A generated mirror with no drift guard is a worse defect than the one it
* fixes, and its failure direction is the silent one: a lucide bump that adds,
* retires or respells a name would narrow what `LazyIcon` draws with nothing
* going red — the same class `scripts/check-lucide-icon-record-names.mjs` exists
* for, one level out. So this file consults lucide's OWN map, which is still
* installed and is still what the gate judges against, and compares the
* reconstruction to it in BOTH directions. The map is a devDependency of the
* test, never of the bundle.
*/
describe('the rebuilt lucide dynamic vocabulary', () => {
it('is EXACTLY what lucide publishes — no name added, none lost', () => {
// Both directions. A superset silently answers `isLucideIconName` yes for an
// invented spelling and then paints the fallback glyph; a subset silently
// stops drawing an icon that used to render.
expect(lucideIconNames()).toEqual([...iconNames].sort());
});

it('is not vacuously equal — the comparison is over a real population', () => {
// The control for the assertion above: two empty lists are also equal.
expect(iconNames.length).toBeGreaterThan(2_000);
expect(lucideIconNames().length).toBe(iconNames.length);
expect(Object.keys(icons).length).toBeGreaterThan(1_700);
// And the two vocabularies really are different, which is the whole reason
// this surface is rebuilt rather than read off the record alone.
expect(iconNames.length).toBeGreaterThan(Object.keys(icons).length);
});

it('draws every published name from the record, not the fallback glyph', () => {
// Membership is worth nothing if the name resolves to `Database` anyway —
// that is precisely the defect objectui#7593 caught on `CheckCircle2`.
// `database` is the one name that draws the fallback component legitimately
// — it IS that icon. Derived from lucide rather than assumed, so a release
// that adds a second spelling for it does not read as a regression.
const drawsDatabase = iconNames.filter((name: string) => getLazyIcon(name) === Database);
const namesOfDatabase = Object.keys(icons).filter(
(key) => icons[key as keyof typeof icons] === Database,
);
expect(namesOfDatabase).toEqual(['Database']);
expect(drawsDatabase).toEqual(['database']);
// Firing control: a name lucide does not publish must still degrade.
expect(getLazyIcon('not-a-lucide-icon-name')).toBe(Database);
expect(isLucideIconName('not-a-lucide-icon-name')).toBe(false);
});

it('keeps the RETIRED spellings the forgiving surface exists for', () => {
// `record-alert`'s severity glyphs. These are ABSENT from the `icons` record
// and present in the dynamic vocabulary, which is the entire reason the two
// surfaces are distinct (objectui#7593, plugin-detail's severityIcons pin).
for (const retired of ['AlertTriangle', 'AlertCircle', 'Edit', 'Smile']) {
expect(isLucideIconName(retired)).toBe(true);
expect(icons).not.toHaveProperty(retired);
}
});

it('every generated alias names a LIVE record key', () => {
const live = new Set(Object.keys(icons));
const entries = Object.entries(LUCIDE_DYNAMIC_NAME_ALIASES);
expect(entries.length).toBeGreaterThan(0);
for (const [name, key] of entries) {
expect(live.has(key)).toBe(true);
expect(iconNames).toContain(name);
}
});

it('every exclusion is a spelling lucide does NOT publish', () => {
// An exclusion that IS a published name would delete a working icon.
expect(LUCIDE_DERIVED_NAME_EXCLUSIONS.length).toBeGreaterThan(0);
for (const name of LUCIDE_DERIVED_NAME_EXCLUSIONS) {
expect(iconNames).not.toContain(name);
}
});
});
Loading
Loading