Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .changeset/9204-lucide-dynamic-map-deferred.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/components': minor
---

Defer lucide's dynamic-import map off the eager path, and publish the icon-name
catalogue it carried (objectui#9204).

lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so importing the
names imports the 2,025-entry dynamic-import map with them. Four modules imported
it statically and the map rode the console's first payload.

- **New export `LUCIDE_ICON_NAMES`** — lucide's dynamic icon vocabulary as data,
generated from the installed lucide and re-derived from it by a test. The
metadata designer's icon picker reads it instead of `lucide-react/dynamic.mjs`.
- `getLazyIcon` / `LazyIcon` / `isLucideIconName` behave exactly as before: the
same normalisation, the same catalogue, the same `Database` fallback. What
changed is when the map arrives — on the first icon that renders, rather than
with the first payload — so an icon shows its fallback glyph for one extra
frame, the same frame `DynamicIcon` already showed while fetching its own
per-icon chunk.

⚠️ This does NOT reduce the eager bundle on its own, and the measurement is in
the PR: the map costs 8,253 B gzipped of the eager `ui-components` chunk, the
catalogue costs 9,176 B in the same chunk, and the net is +923 B. The map's keys
ARE the names, so deferring the map cannot bank its bytes while
`isLucideIconName` stays a synchronous exact-membership predicate over the
DYNAMIC vocabulary.
49 changes: 15 additions & 34 deletions apps/console/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
/**
* 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.
* Synchronous accessor that returns a lazy-loaded Lucide icon React component.
*
* ## Delegated rather than transcribed (objectui#9204)
*
* 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
* This was a third copy of `@object-ui/components`' `getLazyIcon` — the same
* kebab-casing, the same memo, the same `Database` fallback — differing only in
* that it skipped the name check and let lucide log "Name in Lucide DynamicIcon
* not found" for an off-catalog name. Its `lucide-react/dynamic` import put
* lucide's 2,025-entry dynamic-import map on the console's eager path; the
* shared resolver keeps the icon NAMES as data and fetches the map through
* `import()` on first use.
*
* The result is memoised per name inside that resolver, so call sites still 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.
*/
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';
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"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-icon-names": "node scripts/gen-lucide-icon-names.mjs",
"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
78 changes: 24 additions & 54 deletions packages/app-shell/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,35 @@
/**
* 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.
*/

/**
* 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.
* ## One resolver, not a second copy (objectui#9204)
*
* The returned component is memoised per `name` so repeated calls with the
* same name yield the same component reference (stable for React.memo).
* This file used to carry its own transcription of `@object-ui/components`'
* `getLazyIcon`: the same kebab-casing, the same name-membership Set, the same
* per-name memo, the same `Database` fallback. The copy is now a delegation,
* for two reasons that are the same reason:
*
* - the membership Set was built from `iconNames`, and lucide derives that
* from its 2,025-entry dynamic-import map — so this module's import alone
* put that whole map on the console's eager path;
* - two transcriptions of one lookup are two chances to disagree about which
* lucide vocabulary a name is judged against, which is precisely what
* `scripts/check-lucide-icon-record-names.mjs` censuses.
*
* The shared resolver keeps the names as data and reaches the map through
* `import()`. Behaviour here is unchanged: same normalisation, same fallback.
*/
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,
LUCIDE_ICON_NAMES,
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_ICON_NAMES` is the shared catalogue `@object-ui/components` publishes
// as DATA. Read from there rather than from `lucide-react/dynamic.mjs`, whose
// `iconNames` is `Object.keys(dynamicIconImports)` — importing the names
// imports the 2,025-entry map with them, onto the eager path (objectui#9204).
// Freeze the membership Set once for O(1) reuse.
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* 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.
*/

/**
* `LazyIcon` still resolves a real glyph once lucide's dynamic-import map
* arrives (objectui#9204).
*
* The map moved behind an `import()`, which is a byte claim — and the byte
* claim is enforced where bytes are decided: the emitted chunk, by
* `scripts/check-eager-closure-budget.mjs`, and the source shape by
* `scripts/check-lucide-icon-record-names.mjs`'s empty
* `DECLARED_EAGER_DYNAMIC_IMPORTERS`. ⛔ Neither of those is what this file
* tests, and a render test could not: a static import renders identically.
*
* What deferral ADDS is a frame, and that is this file's subject. Before the
* import lands there is no `DynamicIcon` to render, so the icon shows its
* `fallback` — the same glyph `DynamicIcon` itself shows while fetching the
* per-icon chunk, one level down. The failure this pins is the one that would
* ship silently: a slot that renders NOTHING while the map is in flight, or one
* that never leaves the fallback because the promise was dropped.
*/

import { describe, expect, it } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';

import { LazyIcon, getLazyIcon, isLucideIconName } from '../lib/lazy-icon';

/** lucide renders the `Database` fallback with its own `lucide-database` class. */
const isFallbackGlyph = (svg: Element | null) => !!svg?.getAttribute('class')?.includes('lucide-database');

describe('LazyIcon with the import map deferred', () => {
it('shows the fallback glyph first, then the resolved icon', async () => {
const { container } = render(<LazyIcon name="circle-check" data-testid="icon" />);

// The synchronous frame: something is rendered, and it is the fallback.
const first = container.querySelector('svg');
expect(first, 'the slot rendered nothing at all while the map was in flight').not.toBeNull();
expect(isFallbackGlyph(first)).toBe(true);

// …and the promise is not dropped: the real glyph replaces it.
await waitFor(() => {
expect(isFallbackGlyph(container.querySelector('svg'))).toBe(false);
});
expect(container.querySelector('svg')?.getAttribute('class')).toContain('lucide');
cleanup();
});

/**
* The control for the row above. `isFallbackGlyph` going false is only
* evidence of a resolved icon if it STAYS true for a name that cannot
* resolve — otherwise the assertion would pass on any re-render.
*/
it('keeps the fallback for a name outside the catalogue', async () => {
expect(isLucideIconName('no-such-glyph-xyz')).toBe(false);
const { container } = render(<LazyIcon name="no-such-glyph-xyz" />);
await waitFor(() => expect(container.querySelector('svg')).not.toBeNull());
expect(isFallbackGlyph(container.querySelector('svg'))).toBe(true);
cleanup();
});

it('keeps `getLazyIcon` synchronous and memoised per name', () => {
const first = getLazyIcon('circle-check');
expect(typeof first === 'function' || typeof first === 'object').toBe(true);
expect(getLazyIcon('circle-check')).toBe(first);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 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.
*/

/**
* `LUCIDE_ICON_NAMES` is the installed lucide's DYNAMIC vocabulary, not a
* second opinion about it (objectui#9204).
*
* `lazy-icon.tsx` answers `isLucideIconName` from a generated mirror instead of
* importing `iconNames` from `lucide-react/dynamic.mjs`, because lucide derives
* those names as `Object.keys(dynamicIconImports)` — importing them imports the
* 2,025-entry dynamic-import map, which is what put that map on the console's
* eager path.
*
* The mirror buys that with an ageing risk, and it is the risk
* `scripts/check-lucide-icon-record-names.mjs` names in its own header: "a
* hand-kept vocabulary is the same defect one level up — it ages the moment
* lucide retires the next name, and it ages SILENTLY." This file is what makes
* it not silent. It re-derives the list from the SAME install the renderer
* resolves against and fails on any drift, in either direction.
*
* ⛔ The repair for a red here is `pnpm gen:lucide-icon-names`, never an edit to
* the catalogue.
*/

import { describe, expect, it } from 'vitest';
import { iconNames } from 'lucide-react/dynamic.mjs';

import { LUCIDE_ICON_NAMES } from '../lib/lucide-icon-names';

describe('the lucide icon-name catalogue', () => {
/**
* The blind-probe control, first. Every assertion below is an equality
* between two lists; two EMPTY lists are equal, and a comparison that can
* only ever pass reads exactly like a fresh mirror.
*/
it('is comparing two real vocabularies', () => {
expect(Array.isArray(iconNames)).toBe(true);
expect(iconNames.length).toBeGreaterThan(500);
expect(LUCIDE_ICON_NAMES.length).toBeGreaterThan(500);
// A name lucide has carried for years, spelled the way the dynamic surface
// spells it — so "the list is long" is not the only thing checked.
expect(LUCIDE_ICON_NAMES).toContain('database');
expect(LUCIDE_ICON_NAMES).not.toContain('no-such-glyph-xyz');
});

it('is exactly what the installed lucide ships, in order', () => {
expect([...LUCIDE_ICON_NAMES]).toEqual([...(iconNames as readonly string[])]);
});

/**
* Stated separately from the deep-equal above because the two fail for
* different reasons and a reader of the failure needs to know which: a count
* mismatch is a lucide bump nobody regenerated, a same-length mismatch is a
* renamed spelling.
*/
it('carries every name and no extras', () => {
const installed = new Set(iconNames as readonly string[]);
const mirrored = new Set(LUCIDE_ICON_NAMES);
expect([...installed].filter((name) => !mirrored.has(name))).toEqual([]);
expect([...mirrored].filter((name) => !installed.has(name))).toEqual([]);
});
});
5 changes: 5 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export { cn } from './lib/utils';
export { renderChildren } from './lib/utils';
export { cva } from 'class-variance-authority';
export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/lazy-icon';
// lucide's DYNAMIC icon vocabulary as data. Published because the metadata
// designer's icon picker needs the whole list to search, and importing
// `iconNames` from `lucide-react/dynamic.mjs` to get it drags lucide's
// 2,025-entry dynamic-import map in with them (objectui#9204).
export { LUCIDE_ICON_NAMES } from './lib/lucide-icon-names';

// 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
Loading
Loading