From e9344fcfedc00ade413946ecc094ad3116c66863 Mon Sep 17 00:00:00 2001 From: os-dev Date: Sat, 12 Sep 2026 04:13:14 +0000 Subject: [PATCH 1/2] perf(components): defer lucide's dynamic-import map off the eager path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so a static import of either name drags the 1,767-entry dynamic-import map into the importer's chunk. Four modules imported it, and the map rode the console's eager `ui-components` chunk on every page load. - `packages/components/src/lib/lucide-icon-names.ts` — the names as DATA, generated from the installed lucide by `scripts/gen-lucide-icon-names.mjs` and re-derived from that same install by a test, so the mirror cannot age silently. - `lazy-icon.tsx` answers the synchronous `isLucideIconName` from that mirror and reaches the map through `import()` on the first icon that renders. - The two transcriptions of `getLazyIcon` (`app-shell`, `apps/console`) become delegations, so one resolver reads one vocabulary. - `check-lucide-icon-record-names.mjs` learns the two spellings it could not see (`import()`, the catalogue binding) and gains an EMPTY `DECLARED_EAGER_DYNAMIC_IMPORTERS`, so a static import is named on the commit that adds it. Part of #9204 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- apps/console/src/utils/getIcon.ts | 49 +- package.json | 1 + packages/app-shell/src/utils/getIcon.ts | 78 +- .../src/views/metadata-admin/widgets.tsx | 9 +- .../lazy-icon-deferred-map-9204.test.tsx | 71 + .../lucide-icon-names-mirror-9204.test.ts | 67 + packages/components/src/index.ts | 5 + packages/components/src/lib/lazy-icon.tsx | 97 +- .../components/src/lib/lucide-icon-names.ts | 2061 +++++++++++++++++ .../check-lucide-icon-record-names.test.ts | 81 + .../__tests__/gen-lucide-icon-names.test.ts | 56 + scripts/check-lucide-icon-record-names.mjs | 105 +- scripts/gen-lucide-icon-names.mjs | 121 + 13 files changed, 2698 insertions(+), 103 deletions(-) create mode 100644 packages/components/src/__tests__/lazy-icon-deferred-map-9204.test.tsx create mode 100644 packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts create mode 100644 packages/components/src/lib/lucide-icon-names.ts create mode 100644 scripts/__tests__/gen-lucide-icon-names.test.ts create mode 100644 scripts/gen-lucide-icon-names.mjs diff --git a/apps/console/src/utils/getIcon.ts b/apps/console/src/utils/getIcon.ts index 5ecf906246..d39db84b55 100644 --- a/apps/console/src/utils/getIcon.ts +++ b/apps/console/src/utils/getIcon.ts @@ -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(); - -/** - * 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 1,767-entry dynamic-import map on the console's eager path, which is + * the cost this card removes; 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 = (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'; diff --git a/package.json b/package.json index a1ad28128d..75a5f253c9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app-shell/src/utils/getIcon.ts b/packages/app-shell/src/utils/getIcon.ts index e98ed6ec50..830df9525f 100644 --- a/packages/app-shell/src/utils/getIcon.ts +++ b/packages/app-shell/src/utils/getIcon.ts @@ -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); `. - */ - -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 = new Set(iconNames as string[]); - -const cache = new Map(); - -/** - * 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 1,767-entry dynamic-import map — so this module's import alone + * put 263,547 B of rendered 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 = (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'; diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 4d40dd825d..09ce39c401 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -31,6 +31,7 @@ import { Label, Switch, LazyIcon, + LUCIDE_ICON_NAMES, toKebabIconName, Popover, PopoverTrigger, @@ -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'; @@ -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 1,767-entry map with them, onto the eager path (objectui#9204). +// Freeze the membership Set once for O(1) reuse. const LUCIDE_ICON_SET: Set = 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. diff --git a/packages/components/src/__tests__/lazy-icon-deferred-map-9204.test.tsx b/packages/components/src/__tests__/lazy-icon-deferred-map-9204.test.tsx new file mode 100644 index 0000000000..31996bf1c4 --- /dev/null +++ b/packages/components/src/__tests__/lazy-icon-deferred-map-9204.test.tsx @@ -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(); + + // 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(); + 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); + }); +}); diff --git a/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts b/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts new file mode 100644 index 0000000000..54a4994950 --- /dev/null +++ b/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts @@ -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 + * 1,767-entry dynamic-import map, which is what put 263,547 B of rendered 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([]); + }); +}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index d055e84729..6d650f98d3 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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 +// 1,767-entry dynamic-import map onto the eager path (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 / diff --git a/packages/components/src/lib/lazy-icon.tsx b/packages/components/src/lib/lazy-icon.tsx index 5ce39b479f..c09b74565b 100644 --- a/packages/components/src/lib/lazy-icon.tsx +++ b/packages/components/src/lib/lazy-icon.tsx @@ -17,11 +17,38 @@ * The exported `getLazyIcon(name)` API stays synchronous and returns a * React component, preserving call-sites that do * `const Icon = getLazyIcon(name); `. + * + * ## The two halves of `lucide-react/dynamic.mjs`, and why only one is eager + * + * That entry hands out two things this file needs, and lucide derives one from + * the other: `iconNames` is `Object.keys(dynamicIconImports)`. So a static + * import of EITHER name drags the 1,767-entry dynamic-import map into whatever + * chunk holds this module — the console's eager `ui-components` chunk, where it + * was measured at 263,547 B rendered (objectui#9204). + * + * The two halves are needed at different times: + * + * - the NAMES answer `isLucideIconName`, which is synchronous by contract: + * `notificationIcon` (../notifications/severity.ts) chooses between the + * authored icon and the severity glyph DURING RENDER, and an async answer + * there would show the wrong glyph and never correct it. They ship as data, + * from `./lucide-icon-names` — generated from the installed lucide and + * re-derived from it by a test, never hand-kept. + * - the MAP is only ever CALLED, and only after a name has already been + * accepted. It loads through `import()` on the first icon that renders. + * + * ⛔ Do not restore a static `import ... from 'lucide-react/dynamic.mjs'` here + * or anywhere else: `scripts/check-lucide-icon-record-names.mjs` fails on one, + * because it puts the map back on the first payload with nothing red. + * + * While the map is in flight the icon renders its `fallback` — the same frame + * `DynamicIcon` itself shows while fetching the per-icon chunk, so this adds a + * loading STATE to nothing that did not already have one. */ import React from 'react'; import { Database } from 'lucide-react'; -import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs'; +import { LUCIDE_ICON_NAMES } from './lucide-icon-names'; /** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */ export function toKebabIconName(name: string): string { @@ -32,8 +59,8 @@ export function toKebabIconName(name: string): string { .toLowerCase(); } -// Lucide ships ~3900 icon names; storing as a Set keeps lookups O(1). -const VALID_ICON_NAMES: Set = new Set(iconNames as string[]); +// Lucide ships ~2000 icon names; storing as a Set keeps lookups O(1). +const VALID_ICON_NAMES: Set = new Set(LUCIDE_ICON_NAMES); /** Returns true when `kebab` matches a real Lucide icon. */ function isLucideIcon(kebab: string): boolean { @@ -53,6 +80,66 @@ export function isLucideIconName(name?: string): boolean { return !!name && isLucideIcon(toKebabIconName(name)); } +/* -------------------------------------------------------------------------- */ +/* The deferred half: lucide's dynamic-import map */ +/* -------------------------------------------------------------------------- */ + +type LucideDynamicModule = typeof import('lucide-react/dynamic.mjs'); + +/** The loaded module, once it has arrived — read synchronously on later mounts. */ +let dynamicModule: LucideDynamicModule | null = null; +/** The in-flight request, so N icons mounting together make ONE import. */ +let dynamicRequest: Promise | null = null; + +function loadLucideDynamic(): Promise { + dynamicRequest ??= import('lucide-react/dynamic.mjs').then((module) => { + dynamicModule = module; + return module; + }); + return dynamicRequest; +} + +/** + * `DynamicIcon` behind an `import()`, with the caller's fallback showing until + * it lands. + * + * A plain `useState` + `useEffect` rather than `React.lazy`, deliberately: + * `React.lazy` would oblige every one of this package's icon call sites to sit + * under a `` boundary it does not have today, and would suspend a + * whole subtree over one glyph. This mirrors what `DynamicIcon` already does + * internally for the per-icon chunk, one level up. + */ +const DeferredLucideIcon: React.FC<{ name: string; fallback: React.ElementType } & Record> = ({ + name, + fallback, + ...rest +}) => { + const [DynamicIcon, setDynamicIcon] = React.useState( + () => (dynamicModule?.DynamicIcon as React.ElementType | undefined) ?? null, + ); + + React.useEffect(() => { + if (DynamicIcon) return undefined; + let alive = true; + loadLucideDynamic().then( + (module) => { + if (alive) setDynamicIcon(() => module.DynamicIcon as React.ElementType); + }, + (error) => { + // Same shape lucide uses for a per-icon chunk that fails to arrive: say + // so once and keep the fallback glyph, rather than blanking the slot. + console.error('[@object-ui/components] failed to load lucide-react/dynamic.mjs', error); + }, + ); + return () => { + alive = false; + }; + }, [DynamicIcon]); + + if (!DynamicIcon) return React.createElement(fallback, rest); + return React.createElement(DynamicIcon, { name, fallback, ...rest }); +}; + const cache = new Map(); /** @@ -73,7 +160,7 @@ export function getLazyIcon(name?: string): React.ElementType { return Database; } const Wrapped: React.FC = (props) => - React.createElement(DynamicIcon as any, { name: kebab, fallback: Database, ...props }); + React.createElement(DeferredLucideIcon, { name: kebab, fallback: Database, ...props }); Wrapped.displayName = `LucideIcon(${name})`; cache.set(name, Wrapped); return Wrapped; @@ -84,7 +171,7 @@ export const LazyIcon: React.FC<{ name?: string } & Record> = ({ na if (!name) return React.createElement(Database, rest); const kebab = toKebabIconName(name); if (!isLucideIcon(kebab)) return React.createElement(Database, rest); - return React.createElement(DynamicIcon as any, { + return React.createElement(DeferredLucideIcon, { name: kebab, fallback: Database, ...rest, diff --git a/packages/components/src/lib/lucide-icon-names.ts b/packages/components/src/lib/lucide-icon-names.ts new file mode 100644 index 0000000000..68ed35da4f --- /dev/null +++ b/packages/components/src/lib/lucide-icon-names.ts @@ -0,0 +1,2061 @@ +/** + * 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's DYNAMIC icon vocabulary, as data (objectui#9204). + * + * ⛔ GENERATED — do not edit by hand. Run `pnpm gen:lucide-icon-names`. + * + * Every name lucide's `lucide-react/dynamic.mjs` can resolve. It is a strict + * SUPERSET of the runtime `icons` record: it still carries retired spellings + * (`edit`, `smile`, `filter`, `alert-triangle`), which is why + * `scripts/check-lucide-icon-record-names.mjs` judges only the record-reading + * resolver and censuses this surface separately. + * + * ## Why this is a mirror and not an import + * + * lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so + * `import { iconNames } from 'lucide-react/dynamic.mjs'` drags the whole + * 1,767-entry dynamic-import map into whatever chunk holds the importer — + * measured at 263,547 B rendered / 45,749 B gzipped of the console's eager + * `ui-components` chunk. The membership answer is needed synchronously + * (`notificationIcon` chooses between the authored icon and the severity glyph + * during render); the map is needed only AFTER a name has been accepted, and + * `lazy-icon.tsx` reaches it through `import()` for that. + * + * ## Why it cannot age silently + * + * `../__tests__/lucide-icon-names-mirror-9204.test.ts` re-derives this list + * from the installed lucide on every run and fails on any drift. The names are + * data here, never a second opinion about what lucide ships. + */ +export const LUCIDE_ICON_NAMES: readonly string[] = `a-arrow-down +a-arrow-up +a-large-small +accessibility +activity +ad +air-vent +airplay +alarm-clock-check +alarm-check +alarm-clock-minus +alarm-minus +alarm-clock-off +alarm-clock-plus +alarm-plus +alarm-clock +alarm-smoke +album +align-center-horizontal +align-center-vertical +align-end-horizontal +align-end-vertical +align-horizontal-distribute-center +align-horizontal-distribute-end +align-horizontal-distribute-start +align-horizontal-justify-center +align-horizontal-justify-end +align-horizontal-justify-start +align-horizontal-space-around +align-horizontal-space-between +align-start-horizontal +align-start-vertical +align-vertical-distribute-center +align-vertical-distribute-end +align-vertical-distribute-start +align-vertical-justify-center +align-vertical-justify-end +align-vertical-justify-start +align-vertical-space-around +align-vertical-space-between +ambulance +ampersand +ampersands +amphora +anchor +angle +antenna +anvil +aperture +app-window-mac +app-window +apple +archive-restore +archive-x +archive +armchair +arrow-big-down-dash +arrow-big-down +arrow-big-left-dash +arrow-big-left +arrow-big-right-dash +arrow-big-right +arrow-big-up-dash +arrow-big-up +arrow-down-0-1 +arrow-down-01 +arrow-down-1-0 +arrow-down-10 +arrow-down-a-z +arrow-down-az +arrow-down-from-line +arrow-down-left +arrow-down-narrow-wide +arrow-down-right +arrow-down-to-dot +arrow-down-to-line +arrow-down-up +arrow-down-wide-narrow +sort-desc +arrow-down-z-a +arrow-down-za +arrow-down +arrow-left-from-line +arrow-left-right +arrow-left-to-line +arrow-left +arrow-right-from-line +arrow-right-left +arrow-right-to-line +arrow-right +arrow-up-0-1 +arrow-up-01 +arrow-up-1-0 +arrow-up-10 +arrow-up-a-z +arrow-up-az +arrow-up-down +arrow-up-from-dot +arrow-up-from-line +arrow-up-left +arrow-up-narrow-wide +sort-asc +arrow-up-right +arrow-up-to-line +arrow-up-wide-narrow +arrow-up-z-a +arrow-up-za +arrow-up +arrows-up-from-line +asterisk +astroid +at-sign +atom +audio-lines-x +audio-lines +audio-waveform +award +axe +axis-3d +axis-3-d +baby +backpack +badge-alert +badge-cent +badge-check +verified +badge-dollar-sign +badge-euro +badge-indian-rupee +badge-info +badge-japanese-yen +badge-minus +badge-percent +badge-plus +badge-pound-sterling +badge-question-mark +badge-help +badge-russian-ruble +badge-swiss-franc +badge-turkish-lira +badge-x +badge +baggage-claim +balloon +ban +banana +bandage +banknote-arrow-down +banknote-arrow-up +banknote-check +banknote-x +banknote +barcode +barrel +baseline +bath +battery-charging +battery-full +battery-low +battery-medium +battery-plus +battery-warning +battery +beaker +bean-off +bean +bed-double +bed-single +bed +beef-off +beef +beer-off +beer +bell-check +bell-dot +bell-electric +bell-minus +bell-off +bell-plus +bell-ring +bell +between-horizontal-end +between-horizonal-end +between-horizontal-start +between-horizonal-start +between-vertical-end +between-vertical-start +biceps-flexed +bike +binary +binoculars +biohazard +bird +birdhouse +bitcoin +blend +blender +blinds +blocks +bluetooth-connected +bluetooth-off +bluetooth-searching +bluetooth +bold +bolt +bomb +bone-fracture +bone +book-a +book-alert +book-audio +book-check +book-copy +book-dashed +book-template +book-down +book-headphones +book-heart +book-image +book-key +book-lock +book-marked +book-minus +book-open-check +book-open-text +book-open +book-plus +book-search +book-text +book-type +book-up-2 +book-up +book-user +book-x +book +bookmark-check +bookmark-minus +bookmark-off +bookmark-plus +bookmark-x +bookmark +boom-box +bot-message-square +bot-off +bot +bottle-wine +bow-arrow +box +boxes +braces +curly-braces +brackets +brain-circuit +brain-cog +brain +brick-wall-fire +brick-wall-shield +brick-wall +briefcase-business +briefcase-conveyor-belt +briefcase-medical +briefcase +bring-to-front +broccoli +broom-sparkles +broom +brush-cleaning +brush +bubbles +bug-off +bug-play +bug +building-2 +building +bus-front +bus +cable-car +cable +cake-slice +cake +calculator +calendar-1 +calendar-arrow-down +calendar-arrow-up +calendar-check-2 +calendar-check +calendar-clock +calendar-cog +calendar-days +calendar-fold +calendar-heart +calendar-minus-2 +calendar-minus +calendar-off +calendar-plus-2 +calendar-plus +calendar-range +calendar-search +calendar-sync +calendar-x-2 +calendar-x +calendar +calendars +camera-off +camera +candy-cane +candy-off +candy +cannabis-off +cannabis +captions-off +captions +subtitles +car-front +car-taxi-front +car +caravan +card-sim +carrot +case-lower +case-sensitive +case-upper +cassette-tape +cast +castle +cat +cctv-off +cctv +chart-area +area-chart +chart-bar-big +bar-chart-horizontal-big +chart-bar-decreasing +chart-bar-increasing +chart-bar-stacked +chart-bar +bar-chart-horizontal +chart-candlestick +candlestick-chart +chart-column-big +bar-chart-big +chart-column-decreasing +chart-column-increasing +bar-chart-4 +chart-column-stacked +chart-column +bar-chart-3 +chart-gantt +chart-line +line-chart +chart-network +chart-no-axes-column-decreasing +chart-no-axes-column-increasing +bar-chart +chart-no-axes-column +bar-chart-2 +chart-no-axes-combined +chart-no-axes-gantt +gantt-chart +chart-pie +pie-chart +chart-scatter +scatter-chart +chart-spline +check-check +check-line +check +chef-hat +cherry +chess-bishop +chess-king +chess-knight +chess-pawn +chess-queen +chess-rook +chevron-down +chevron-first +chevron-last +chevron-left +chevron-right +chevron-up +chevrons-down-up +chevrons-down +chevrons-left-right-ellipsis +chevrons-left-right +chevrons-left +chevrons-right-left +chevrons-right +chevrons-up-down +chevrons-up +church +cigarette-off +cigarette +circle-alert +alert-circle +circle-arrow-down +arrow-down-circle +circle-arrow-left +arrow-left-circle +circle-arrow-out-down-left +arrow-down-left-from-circle +circle-arrow-out-down-right +arrow-down-right-from-circle +circle-arrow-out-up-left +arrow-up-left-from-circle +circle-arrow-out-up-right +arrow-up-right-from-circle +circle-arrow-right +arrow-right-circle +circle-arrow-up +arrow-up-circle +circle-check-big +check-circle +circle-check +check-circle-2 +circle-chevron-down +chevron-down-circle +circle-chevron-left +chevron-left-circle +circle-chevron-right +chevron-right-circle +circle-chevron-up +chevron-up-circle +circle-dashed +circle-divide +divide-circle +circle-dollar-sign +circle-dot-dashed +circle-dot +circle-ellipsis +circle-equal +circle-euro +circle-fading-arrow-up +circle-fading-plus +circle-gauge +gauge-circle +circle-minus +minus-circle +circle-off +circle-parking-off +parking-circle-off +circle-parking +parking-circle +circle-pause +pause-circle +circle-percent +percent-circle +circle-pile +circle-play +play-circle +circle-plus +plus-circle +circle-pound-sterling +circle-power +power-circle +circle-question-mark +help-circle +circle-help +circle-slash-2 +circle-slashed +circle-slash +circle-small +circle-star +circle-stop +stop-circle +circle-user-round +user-circle-2 +circle-user +user-circle +circle-x +x-circle +circle +circuit-board +citrus +clapperboard +clipboard-check +clipboard-clock +clipboard-copy +clipboard-list +clipboard-minus +clipboard-paste +clipboard-pen-line +clipboard-signature +clipboard-pen +clipboard-edit +clipboard-plus +clipboard-type +clipboard-x +clipboard +clock-1 +clock-10 +clock-11 +clock-12 +clock-2 +clock-3 +clock-4 +clock-5 +clock-6 +clock-7 +clock-8 +clock-9 +clock-alert +clock-arrow-down +clock-arrow-left +clock-arrow-right +clock-arrow-up +clock-check +clock-fading +clock-plus +clock +closed-caption +cloud-alert +cloud-backup +cloud-check +cloud-cog +cloud-download +download-cloud +cloud-drizzle +cloud-fog +cloud-hail +cloud-lightning +cloud-moon-rain +cloud-moon +cloud-off +cloud-rain-wind +cloud-rain +cloud-snow +cloud-sun-rain +cloud-sun +cloud-sync +cloud-upload +upload-cloud +cloud +cloudy +clover +club +code-xml +code-2 +code +coffee +cog +coins +columns-2 +columns +columns-3-cog +columns-settings +table-config +columns-3 +panels-left-right +columns-4 +combine +command +compass +component +computer +concierge-bell +cone +construction +contact-round +contact-2 +contact +container +contrast +cookie +cooking-pot +copy-check +copy-minus +copy-plus +copy-slash +copy-x +copy +copyleft +copyright +corner-down-left +corner-down-right +corner-left-down +corner-left-up +corner-right-down +corner-right-up +corner-up-left +corner-up-right +cpu +creative-commons +credit-card +croissant +crop +cross +crosshair +crown +cuboid +cup-soda +currency +cylinder +dam +database-arrow-down +database-arrow-up +database-backup +database-check +database-minus +database-plus +database-search +database-x +database-zap +database +decimals-arrow-left +decimals-arrow-right +delete +dessert +diameter +diamond-minus +diamond-percent +percent-diamond +diamond-plus +diamond +dice-1 +dice-2 +dice-3 +dice-4 +dice-5 +dice-6 +dices +diff +disc-2 +disc-3 +disc-album +disc +divide +dna-off +dna +dock +dog +dollar-sign +donut +door-closed-locked +door-closed +door-open +dot +download +drafting-compass +drama +drill +drone +droplet-off +droplet +droplets +drum +drumstick +dumbbell +ear-off +ear +earth-lock +earth +globe-2 +eclipse +egg-fried +egg-off +egg +eject +ellipse +ellipsis-vertical +more-vertical +ellipsis +more-horizontal +equal-approximately +equal-not +equal +eraser +ethernet-port +euro +ev-charger +expand +external-link +eye-closed +eye-dashed +eye-off +eye +face-angry +angry +face-expressionless +annoyed +face-grinning +laugh +face-neutral +meh +face-slightly-frowning +frown +face-slightly-smiling-plus +smile-plus +face-slightly-smiling +smile +factory +fan +fast-forward +feather +fence +ferris-wheel +file-archive +file-axis-3d +file-axis-3-d +file-badge +file-badge-2 +file-box +file-braces-corner +file-json-2 +file-braces +file-json +file-chart-column-increasing +file-bar-chart +file-chart-column +file-bar-chart-2 +file-chart-line +file-line-chart +file-chart-pie +file-pie-chart +file-check-corner +file-check-2 +file-check +file-clock +file-code-corner +file-code-2 +file-code +file-cog +file-cog-2 +file-diff +file-digit +file-down +file-exclamation-point +file-warning +file-headphone +file-audio +file-audio-2 +file-heart +file-image +file-input +file-key +file-key-2 +file-lock +file-lock-2 +file-minus-corner +file-minus-2 +file-minus +file-music +file-output +file-pen-line +file-signature +file-pen +file-edit +file-play +file-video +file-plus-corner +file-plus-2 +file-plus +file-question-mark +file-question +file-scan +file-search-corner +file-search-2 +file-search +file-signal +file-volume-2 +file-sliders +file-spreadsheet +file-stack +file-symlink +file-terminal +file-text +file-type-corner +file-type-2 +file-type +file-up +file-user +file-video-camera +file-video-2 +file-volume +file-x-corner +file-x-2 +file-x +file +files +film +fingerprint-pattern +fingerprint +fire-extinguisher +fish-off +fish-symbol +fish +fishing-hook +fishing-rod +flag-off +flag-triangle-left +flag-triangle-right +flag +flame-kindling +flame +flashlight-off +flashlight +flask-conical-off +flask-conical +flask-round +flip-horizontal-2 +flip-vertical-2 +flower-2 +flower +focus +fold-horizontal +fold-vertical +folder-archive +folder-bookmark +folder-check +folder-clock +folder-closed +folder-code +folder-cog +folder-cog-2 +folder-dot +folder-down +folder-git-2 +folder-git +folder-heart +folder-input +folder-kanban +folder-key +folder-lock +folder-minus +folder-open-dot +folder-open +folder-output +folder-pen +folder-edit +folder-plus +folder-root +folder-search-2 +folder-search +folder-symlink +folder-sync +folder-tree +folder-up +folder-x +folder +folders +footprints +forklift +form +forward +frame +fuel +fullscreen +funnel-plus +funnel-x +filter-x +funnel +filter +gallery-horizontal-end +gallery-horizontal +gallery-thumbnails +gallery-vertical-end +gallery-vertical +gamepad-2 +gamepad-directional +gamepad +gauge +gavel +gem +georgian-lari +ghost +gift +git-branch-minus +git-branch-plus +git-branch +git-commit-horizontal +git-commit +git-commit-vertical +git-compare-arrows +git-compare +git-fork +git-graph +git-merge-conflict +git-merge +git-pull-request-arrow +git-pull-request-closed +git-pull-request-create-arrow +git-pull-request-create +git-pull-request-draft +git-pull-request +glass-water +glasses +globe-check +globe-lock +globe-off +globe-x +globe +goal +gpu +graduation-cap +grape +grid-2x2-check +grid-2-x-2-check +grid-2x2-plus +grid-2-x-2-plus +grid-2x2-x +grid-2-x-2-x +grid-2x2 +grid-2-x-2 +grid-3x2 +grid-3x3 +grid +grid-3-x-3 +grip-horizontal +grip-vertical +grip +group +guitar +ham +hamburger +hammer +hand-coins +hand-fist +hand-grab +grab +hand-heart +hand-helping +helping-hand +hand-metal +hand-platter +hand +handbag +handshake +hard-drive-download +hard-drive-upload +hard-drive +hard-hat +hash +hat-glasses +haze +hd +hdmi-port +heading-1 +heading-2 +heading-3 +heading-4 +heading-5 +heading-6 +heading +headphone-off +headphones +headset +heart-crack +heart-handshake +heart-minus +heart-off +heart-plus +heart-pulse +heart-x +heart +heater +helicopter +hexagon +highlighter +hop-off +hop +hospital +hotel +hourglass +house-heart +house-plug +house-plus +house-wifi +house +home +ice-cream-bowl +ice-cream-2 +ice-cream-cone +ice-cream +id-card-lanyard +id-card +image-down +image-minus +image-off +image-play +image-plus +image-up +image-upscale +image +images +import +inbox +indian-rupee +infinity +info +inspection-panel +italic +iteration-ccw +iteration-cw +japanese-yen +joystick +kanban +kayak +key-round +key-square +key +keyboard-music +keyboard-off +keyboard +lamp-ceiling +lamp-desk +lamp-floor +lamp-wall-down +lamp-wall-up +lamp +land-plot +landmark +languages +laptop-minimal-check +laptop-minimal +laptop-2 +laptop +lasso-select +lasso +layer-arrow-down +layer-arrow-up +layers-2 +layers-arrow-down +layers-arrow-up +layers-minus +layers-plus +layers +layers-3 +layout-dashboard +layout-freeform +layout-grid +layout-list +layout-panel-left +layout-panel-top +layout-template +leaf +leafy-green +lectern +lens-concave +lens-convex +library-big +library +life-buoy +ligature +lightbulb-off +lightbulb +line-dot-right-horizontal +line-squiggle +line-style +link-2-off +link-2 +link +list-check +list-checks +list-chevrons-down-up +list-chevrons-up-down +list-collapse +list-end +list-filter-plus +list-filter +list-indent-decrease +outdent +indent-decrease +list-indent-increase +indent +indent-increase +list-minus +list-music +list-ordered +list-plus +list-restart +list-sort-ascending +list-sort-descending +list-start +list-todo +list-tree +list-video +list-x +list +loader-circle +loader-2 +loader-pinwheel +loader +locate-fixed +locate-off +locate +lock-keyhole-open +unlock-keyhole +lock-keyhole +lock-open +unlock +lock +log-in +log-out +logs +lollipop +luggage +magnet +mail-badge +mail-check +mail-minus +mail-open +mail-plus +mail-question-mark +mail-question +mail-search +mail-warning +mail-x +mail +mailbox +mails +map-minus +map-pin-check-inside +map-pin-check +map-pin-house +map-pin-minus-inside +map-pin-minus +map-pin-off +map-pin-pen +location-edit +map-pin-plus-inside +map-pin-plus +map-pin-search +map-pin-x-inside +map-pin-x +map-pin +map-pinned +map-plus +map +mars-stroke +mars +martini +maximize-2 +maximize +medal +megaphone-off +megaphone +memory-stick +menu +merge +message-circle-check +message-circle-code +message-circle-dashed +message-circle-heart +message-circle-more +message-circle-off +message-circle-plus +message-circle-question-mark +message-circle-question +message-circle-reply +message-circle-warning +message-circle-x +message-circle +message-square-check +message-square-code +message-square-dashed +message-square-diff +message-square-dot +message-square-heart +message-square-lock +message-square-more +message-square-off +message-square-plus +message-square-quote +message-square-reply +message-square-share +message-square-text +message-square-warning +message-square-x +message-square +messages-square +metronome +mic-audio-lines +mic-off +mic-signal +podcast +mic-vocal +mic-2 +mic +microchip +microscope +microwave +milestone +milk-off +milk +minimize-2 +minimize +minus +mirror-rectangular +mirror-round +monitor-check +monitor-cloud +monitor-cog +monitor-dot +monitor-down +monitor-off +monitor-pause +monitor-play +monitor-smartphone +monitor-speaker +monitor-stop +monitor-up +monitor-x +monitor +moon-star +moon +mosque +motorbike +mountain-snow +mountain +mouse-left +mouse-off +mouse-pointer-2-off +mouse-pointer-2 +mouse-pointer-ban +mouse-pointer-click +mouse-pointer +mouse-right +mouse +move-3d +move-3-d +move-diagonal-2 +move-diagonal +move-down-left +move-down-right +move-down +move-horizontal +move-left +move-right +move-up-left +move-up-right +move-up +move-vertical +move +music-2 +music-3 +music-4 +music +navigation-2-off +navigation-2 +navigation-off +navigation +network +newspaper +nfc +non-binary +notebook-pen +notebook-tabs +notebook-text +notebook +notepad-text-dashed +notepad-text +nut-off +nut +octagon-alert +alert-octagon +octagon-minus +octagon-pause +pause-octagon +octagon-x +x-octagon +octagon +omega +option +orbit +origami +package-2 +package-check +package-minus +package-open +package-plus +package-search +package-x +package +paint-bucket +paint-roller +paintbrush-vertical +paintbrush-2 +paintbrush +palette +panda +panel-bottom-close +panel-bottom-dashed +panel-bottom-inactive +panel-bottom-open +panel-bottom +panel-left-close +sidebar-close +panel-left-dashed +panel-left-inactive +panel-left-open +sidebar-open +panel-left-right-dashed +panel-left +sidebar +panel-right-close +panel-right-dashed +panel-right-inactive +panel-right-open +panel-right +panel-top-bottom-dashed +panel-top-close +panel-top-dashed +panel-top-inactive +panel-top-open +panel-top +panels-left-bottom +panels-right-bottom +panels-top-left +layout +paper-bag +paperclip +parasol +parentheses +parking-meter +party-popper +pause +paw-print +pc-case +pen-line +edit-3 +pen-off +pen-tool +pen +edit-2 +pencil-line +pencil-off +pencil-ruler +pencil-sparkles +pencil +pentagon +percent +person-standing +phi +philippine-peso +phone-call +phone-forwarded +phone-incoming +phone-missed +phone-off +phone-outgoing +phone +pi +piano +pickaxe +picture-in-picture-2 +picture-in-picture +piggy-bank +pilcrow-left +pilcrow-right +pilcrow +pill-bottle +pill +pin-off +pin +pipette +pizza +plane-landing +plane-takeoff +plane +play-off +play +plug-2 +plug-zap +plug-zap-2 +plug +plus +pocket-knife +podium +pointer-off +pointer +popcorn +popsicle +pound-sterling +power-off +power +presentation +printer-check +printer-x +printer +projector +proportions +puzzle +pyramid +qr-code +quote +rabbit +radar +radiation +radical +radio-off +radio-receiver +radio-tower +radio +radius +rainbow +rat +ratio +receipt-cent +receipt-euro +receipt-indian-rupee +receipt-japanese-yen +receipt-pound-sterling +receipt-russian-ruble +receipt-swiss-franc +receipt-text +receipt-turkish-lira +receipt +rectangle-circle +rectangle-ellipsis +form-input +rectangle-goggles +rectangle-horizontal +rectangle-vertical +recycle +redo-2 +redo-dot +redo +refresh-ccw-dot +refresh-ccw +refresh-cw-off +refresh-cw +refrigerator +regex +remove-formatting +repeat-1 +repeat-2 +repeat-off +repeat +replace-all +replace +reply-all +reply +rewind +ribbon +road +rocket +rocking-chair +roller-coaster +rose +rotate-3d +rotate-3-d +rotate-ccw-clock +history +rotate-ccw-key +rotate-ccw-square +rotate-ccw +rotate-cw-fading-clock +rotate-cw-square +rotate-cw +route-off +route +router +rows-2 +rows +rows-3 +panels-top-bottom +rows-4 +rss +ruler-dimension-line +ruler +russian-ruble +sailboat +salad +sandwich +satellite-dish +satellite +saudi-riyal +save-all +save-check +save-off +save-pen +save-plus +save +scale-3d +scale-3-d +scale +scaling +scan-barcode +scan-box +scan-eye +scan-face +scan-heart +scan-line +scan-qr-code +scan-search +scan-square +scan-text +scan +school +scissors-line-dashed +scissors +scooter +screen-share-off +screen-share +scroll-text +scroll +search-alert +search-check +search-code +search-slash +search-x +search +section +send-horizontal +send-horizonal +send-to-back +send +separator-horizontal +separator-vertical +server-cog +server-crash +server-off +server-plus +server +settings-2 +settings +shapes +share-2 +share +sheet +shell +shelving-unit +shield-alert +shield-ban +shield-check +shield-cog-corner +shield-cog +shield-ellipsis +shield-half +shield-keyhole +shield-lock +shield-minus +shield-off +shield-plus +shield-question-mark +shield-question +shield-user +shield-x +shield-close +shield +ship-wheel +ship +shirt +shopping-bag +shopping-basket +shopping-cart +shovel +shower-head +shredder +shrimp +shrink +shrub +shuffle +sigma +signal-high +signal-low +signal-medium +signal-zero +signal +signature +signpost-big +signpost +siren +skip-back +skip-forward +skull +slash +slice +sliders-horizontal +sliders-vertical +sliders +smartphone-charging +smartphone-nfc +smartphone +snail +snowflake +soap-dispenser-droplet +sofa +solar-panel +soup +space +spade +sparkle +sparkles +stars +speaker +speech +spell-check-2 +spell-check +spline-pointer +spline +split +spool +sport-shoe +spotlight +spray-can +sprout +square-activity +activity-square +square-arrow-down-left +arrow-down-left-square +square-arrow-down-right +arrow-down-right-square +square-arrow-down +arrow-down-square +square-arrow-left +arrow-left-square +square-arrow-out-down-left +arrow-down-left-from-square +square-arrow-out-down-right +arrow-down-right-from-square +square-arrow-out-up-left +arrow-up-left-from-square +square-arrow-out-up-right +arrow-up-right-from-square +square-arrow-right-enter +square-arrow-right-exit +square-arrow-right +arrow-right-square +square-arrow-up-left +arrow-up-left-square +square-arrow-up-right +arrow-up-right-square +square-arrow-up +arrow-up-square +square-asterisk +asterisk-square +square-bottom-dashed-scissors +scissors-square-dashed-bottom +square-centerline-dashed-horizontal +flip-horizontal +square-centerline-dashed-vertical +flip-vertical +square-chart-gantt +gantt-chart-square +square-gantt-chart +square-check-big +check-square +square-check +check-square-2 +square-chevron-down +chevron-down-square +square-chevron-left +chevron-left-square +square-chevron-right +chevron-right-square +square-chevron-up +chevron-up-square +square-code +code-square +square-dashed-bottom-code +square-dashed-bottom +square-dashed-kanban +kanban-square-dashed +square-dashed-mouse-pointer +mouse-pointer-square-dashed +square-dashed-text +text-selection +text-select +square-dashed-top-solid +square-dashed +box-select +square-divide +divide-square +square-dot +dot-square +square-equal +equal-square +square-function +function-square +square-kanban +kanban-square +square-library +library-square +square-m +m-square +square-menu +menu-square +square-minus +minus-square +square-mouse-pointer +inspect +square-off +square-parking-off +parking-square-off +square-parking +parking-square +square-pause +square-pen +pen-box +edit +pen-square +square-percent +percent-square +square-pi +pi-square +square-pilcrow +pilcrow-square +square-play +play-square +square-plus +plus-square +square-power +power-square +square-radical +square-round-corner +square-scissors +scissors-square +square-sigma +sigma-square +square-slash +slash-square +square-split-horizontal +split-square-horizontal +square-split-vertical +split-square-vertical +square-square +square-stack +square-star +square-stop +square-terminal +terminal-square +square-user-round +user-square-2 +square-user +user-square +square-x +x-square +square +squares-exclude +squares-intersect +squares-subtract +squares-unite +squircle-dashed +squircle +squirrel +stamp +star-check +star-half +star-minus +star-off +star-plus +star-x +star +step-back +step-forward +stethoscope +sticker +sticky-note-check +sticky-note-minus +sticky-note-off +sticky-note-plus +sticky-note-x +sticky-note +sticky-notes +stone +store +stretch-horizontal +stretch-vertical +strikethrough +subscript +summary +sun-dim +sun-medium +sun-moon +sun-snow +sun +sunrise +sunset +superscript +swatch-book +swiss-franc +switch-camera +sword +swords +syringe +table-2 +table-cells-merge +table-cells-split +table-columns-split +table-of-contents +table-properties +table-rows-split +table +tablet-smartphone +tablet +tablets +tag-plus +tag-x +tag +tags +tally-1 +tally-2 +tally-3 +tally-4 +tally-5 +tangent +target +telescope +tent-tree +tent +terminal +test-tube-diagonal +test-tube-2 +test-tube +test-tubes +text-align-center +align-center +text-align-end +align-right +text-align-justify +align-justify +text-align-start +text +align-left +text-cursor-input +text-cursor +text-initial +letter-text +text-quote +text-search +text-wrap +wrap-text +theater +thermometer-snowflake +thermometer-sun +thermometer +thumbs-down +thumbs-up +ticket-check +ticket-minus +ticket-percent +ticket-plus +ticket-slash +ticket-x +ticket +tickets-plane +tickets +timeline +timer-off +timer-reset +timer +toggle-left +toggle-right +toilet +tool-case +toolbox +tornado +torus +touchpad-off +touchpad +towel-rack +tower-control +toy-brick +tractor +traffic-cone +train-front-tunnel +train-front +train-track +tram-front +train +transgender +trash-2 +trash +tree-deciduous +tree-palm +palmtree +tree-pine +trees +trending-down +trending-up-down +trending-up +triangle-alert +alert-triangle +triangle-dashed +triangle-right +triangle +trophy +truck-electric +truck +turkish-lira +turntable +turtle +tv-minimal-play +tv-minimal +tv-2 +tv +type-outline +type +umbrella-off +umbrella +underline +undo-2 +undo-dot +undo +unfold-horizontal +unfold-vertical +ungroup +university +school-2 +unlink-2 +unlink +unplug +upload +usb +user-check +user-cog +user-key +user-lock +user-minus +user-pen +user-plus +user-round-arrow-left +user-round-check +user-check-2 +user-round-cog +user-cog-2 +user-round-key +user-round-minus +user-minus-2 +user-round-pen +user-round-plus +user-plus-2 +user-round-search +user-round-x +user-x-2 +user-round +user-2 +user-search +user-shield +user-star +user-x +user +users-round +users-2 +users +utensils-crossed +fork-knife-crossed +utensils +fork-knife +utility-pole +van +variable +vault +vector-square +vegan +venetian-mask +venus-and-mars +venus +vibrate-off +vibrate +video-off +video +videotape +view +voicemail +volleyball +volume-1 +volume-2 +volume-off +volume-x +volume +vote +wallet-cards +wallet-minimal +wallet-2 +wallet +wallpaper +wand-sparkles +wand-2 +wand +warehouse +washing-machine +watch +waves-arrow-down +waves-arrow-up +waves-horizontal +waves +waves-ladder +waves-vertical +waypoints +webcam-off +webcam +webhook-off +webhook +weight-tilde +weight +wheat-off +wheat +whole-word +wifi-cog +wifi-high +wifi-low +wifi-off +wifi-pen +wifi-sync +wifi-zero +wifi +wind-arrow-down +wind +wine-off +wine +workflow +worm +wrench-off +wrench +x-line-top +x +zap-off +zap +zodiac-aquarius +zodiac-aries +zodiac-cancer +zodiac-capricorn +zodiac-gemini +zodiac-leo +zodiac-libra +zodiac-ophiuchus +zodiac-pisces +zodiac-sagittarius +zodiac-scorpio +zodiac-taurus +zodiac-virgo +zoom-in +zoom-out`.split('\n'); diff --git a/scripts/__tests__/check-lucide-icon-record-names.test.ts b/scripts/__tests__/check-lucide-icon-record-names.test.ts index 8342f1ee93..d1f9a2c97a 100644 --- a/scripts/__tests__/check-lucide-icon-record-names.test.ts +++ b/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { ANCHORED_MAPS, DECLARED_DYNAMIC_READERS, + DECLARED_EAGER_DYNAMIC_IMPORTERS, DECLARED_RECORD_READERS, DISCOVERY_NEGATIVE_CONTROL, RECORD_READING_TYPES, @@ -107,6 +108,7 @@ interface FixtureOptions { anchors?: typeof ANCHORED_MAPS; declaredRecordReaders?: string[]; declaredDynamicReaders?: string[]; + declaredEagerDynamicImporters?: string[]; negativeControl?: string; recordReadingTypes?: CensusTable; } @@ -146,6 +148,7 @@ function judge(label: string, options: FixtureOptions) { anchors: options.anchors ?? [], declaredRecordReaders: options.declaredRecordReaders ?? [RESOLVER_FILE], declaredDynamicReaders: options.declaredDynamicReaders ?? [], + declaredEagerDynamicImporters: options.declaredEagerDynamicImporters, negativeControl: options.negativeControl, recordReadingTypes: options.recordReadingTypes ?? FIXTURE_TYPES, }); @@ -721,6 +724,11 @@ describe('the surface census is re-derived on every run', () => { // Getting this backwards is worse than having no gate: the dynamic list // still carries `edit`, so a gate pointed at it would bless the exact names // this class is about. + // + // The static spelling is deliberate here and so is the allowance beside it: + // this row is about WHICH vocabulary the site reads, and the eager-import + // rule below is about HOW it reaches it. Keeping them apart is what lets + // either fail alone. const result = judge('dynamic', { files: { 'packages/app/src/lazy.ts': [ @@ -729,6 +737,7 @@ describe('the surface census is re-derived on every run', () => { ].join('\n'), }, declaredDynamicReaders: ['packages/app/src/lazy.ts'], + declaredEagerDynamicImporters: ['packages/app/src/lazy.ts'], }); expect(result.errors).toEqual([]); @@ -736,6 +745,66 @@ describe('the surface census is re-derived on every run', () => { expect(result.discovered.record).toEqual([RESOLVER_FILE]); }); + // ── objectui#9204: HOW a site reaches the dynamic surface is censused too ── + + it('sees the DEFERRED spelling — an `import()` still reads the vocabulary', () => { + // The census exists so a site cannot move between surfaces unnoticed. + // Moving the map behind `import()` must not read as "stopped reading it": + // that would retire the entry and leave the next static import undeclared + // AND unnoticed. + const result = judge('deferred', { + files: { + 'packages/app/src/deferred.ts': [ + "export const load = () => import('lucide-react/dynamic.mjs');", + ].join('\n'), + }, + declaredDynamicReaders: ['packages/app/src/deferred.ts'], + }); + + expect(result.errors).toEqual([]); + expect(result.discovered.dynamic).toEqual(['packages/app/src/deferred.ts']); + expect(result.discovered.eagerDynamic).toEqual([]); + }); + + it('sees the CATALOGUE binding — the mirror is that vocabulary', () => { + // `LUCIDE_ICON_NAMES` is lucide's dynamic vocabulary as data. A module + // reading it resolves names against that surface just as much as one + // importing `iconNames`, and does it without mentioning `lucide-react` at + // all — which is also why the prefilter has to admit the file. + const result = judge('catalogue', { + files: { + 'packages/app/src/picker.ts': [ + "import { LUCIDE_ICON_NAMES } from '@object-ui/components';", + 'export const known = new Set(LUCIDE_ICON_NAMES);', + ].join('\n'), + }, + declaredDynamicReaders: ['packages/app/src/picker.ts'], + }); + + expect(result.errors).toEqual([]); + expect(result.discovered.dynamic).toEqual(['packages/app/src/picker.ts']); + expect(result.discovered.eagerDynamic).toEqual([]); + }); + + it('fails on a STATIC import of the dynamic entry — that is the 263 KB map', () => { + // lucide derives `iconNames` from `dynamicIconImports`, so this import puts + // the 1,767-entry map in the importer's chunk. Nothing else in the tree + // reddens: the laziness is in the source and the cost is in a bundle. + const result = judge('eager-dynamic', { + files: { + 'packages/app/src/eager.ts': [ + "import { iconNames } from 'lucide-react/dynamic.mjs';", + 'export const known = new Set(iconNames as string[]);', + ].join('\n'), + }, + declaredDynamicReaders: ['packages/app/src/eager.ts'], + }); + + expect(result.violations).toEqual([]); + expect(result.discovered.eagerDynamic).toEqual(['packages/app/src/eager.ts']); + expect(result.errors.join('\n')).toContain('EAGER `lucide-react/dynamic` import: packages/app/src/eager.ts'); + }); + it('matches the IMPORT, not the name — a local `icons` object is not a resolver', () => { // The blind-probe control for discovery. `plugin-chatbot/src/elements/tool.tsx` // is the live specimen: it builds its own `icons` map of ReactNodes and @@ -944,6 +1013,18 @@ describe('this repository', () => { expect(repoResult.discovered.dynamic).toEqual([...DECLARED_DYNAMIC_READERS].sort()); }); + it('has NO module importing lucide\'s dynamic entry statically', () => { + // objectui#9204's whole deliverable, stated where it can go red: the + // declared allowance is empty, and so is what discovery finds. + expect(DECLARED_EAGER_DYNAMIC_IMPORTERS).toEqual([]); + expect(repoResult.discovered.eagerDynamic).toEqual([]); + + // …and the surface it guards has not evaporated. Two lists that are both + // empty because discovery stopped working read exactly like a clean tree, + // so the dynamic census is shown non-empty first. + expect(repoResult.discovered.dynamic.length).toBeGreaterThan(0); + }); + it('does not mistake the live local-`icons` specimen for a resolver', () => { expect(fs.existsSync(path.join(repoRoot, DISCOVERY_NEGATIVE_CONTROL))).toBe(true); expect(repoResult.discovered.record).not.toContain(DISCOVERY_NEGATIVE_CONTROL); diff --git a/scripts/__tests__/gen-lucide-icon-names.test.ts b/scripts/__tests__/gen-lucide-icon-names.test.ts new file mode 100644 index 0000000000..2687086b7a --- /dev/null +++ b/scripts/__tests__/gen-lucide-icon-names.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The checked-in icon catalogue is what the generator writes (objectui#9204). + * + * `packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts` + * holds the SEMANTIC half — the names in the catalogue are the names the + * installed lucide ships. This file holds the mechanical half: running + * `pnpm gen:lucide-icon-names` reproduces the file on disk byte for byte. + * + * Both are needed, and they fail for different reasons. A catalogue edited by + * hand into the right SHAPE but the wrong bytes — a re-wrapped header, a + * stripped `readonly`, names re-sorted "helpfully" — keeps the semantic test + * green while making the generator's output a diff nobody expects. That is how + * a generated file stops being regenerated. + */ + +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + CATALOGUE_PATH, + loadInstalledIconNames, + renderCatalogue, +} from '../gen-lucide-icon-names.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('the generated lucide icon catalogue', () => { + it('is byte-identical to what the generator produces from the installed lucide', async () => { + const { names } = await loadInstalledIconNames(repoRoot); + const onDisk = fs.readFileSync(path.join(repoRoot, CATALOGUE_PATH), 'utf8'); + expect( + renderCatalogue(names), + `${CATALOGUE_PATH} is not what the generator writes — run \`pnpm gen:lucide-icon-names\``, + ).toBe(onDisk); + }); + + /** + * The control. `toBe` between two strings is only evidence if a WRONG input + * would have produced a different string; a renderer that ignored its + * argument would pass the row above forever. + */ + it('renders a different file for a different vocabulary', async () => { + const { names } = await loadInstalledIconNames(repoRoot); + expect(renderCatalogue([...names, 'no-such-glyph-xyz'])).not.toBe(renderCatalogue(names)); + expect(renderCatalogue([...names, 'no-such-glyph-xyz'])).toContain('no-such-glyph-xyz'); + }); + + it('names the repair in the file it writes', async () => { + const { names } = await loadInstalledIconNames(repoRoot); + expect(renderCatalogue(names)).toContain('pnpm gen:lucide-icon-names'); + }); +}); diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 3d38830368..8b073640cc 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -41,6 +41,21 @@ * judged here; the dynamic sites are censused (below) precisely so that the * split stays declared and a site cannot move between surfaces unnoticed. * + * ── HOW a site reaches DYNAMIC is also censused (objectui#9204) ───────────── + * lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so importing + * the names imports the 1,767-entry dynamic-import map with them. Four modules + * did, and the map — 263,547 B rendered — sat in the console's eager + * `ui-components` chunk on every page load. Two of those were transcriptions of + * `getLazyIcon` and are now delegations; the surviving pair reads the names from + * `LUCIDE_ICON_NAMES`, a generated mirror of that same vocabulary, and reaches + * the map through `import()`. + * + * That makes three spellings discovery has to see — a static import, an + * `import()`, and the catalogue binding — and gives this gate a second census: + * `DECLARED_EAGER_DYNAMIC_IMPORTERS`, which is EMPTY. A static import restores + * the map to the first payload while every other check stays green, because the + * laziness lives in the source and the cost lives in a bundle. Here they meet. + * * ── What it checks (three parts, each self-verifying) ─────────────────────── * 1. SURFACE CENSUS — rediscovers, from source, every module that reads either * vocabulary, and fails when the discovered set differs from the declared @@ -298,12 +313,45 @@ export const DECLARED_RECORD_READERS = [ ]; export const DECLARED_DYNAMIC_READERS = [ - 'apps/console/src/utils/getIcon.ts', - 'packages/app-shell/src/utils/getIcon.ts', 'packages/app-shell/src/views/metadata-admin/widgets.tsx', 'packages/components/src/lib/lazy-icon.tsx', ]; +/** + * The DYNAMIC surface reaches source two ways, and discovery has to see both. + * + * - `lucide-react/dynamic.mjs` itself, statically or through `import()`; + * - `LUCIDE_ICON_NAMES`, the catalogue `@object-ui/components` publishes. + * + * The catalogue is that vocabulary as DATA — generated from the installed + * lucide by `scripts/gen-lucide-icon-names.mjs` and re-derived from the same + * install by `packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts`, + * so it is a mirror rather than the hand-kept list this gate's header warns + * about. Reading it is reading the dynamic surface, and the census says so. + */ +export const DYNAMIC_CATALOGUE_BINDING = 'LUCIDE_ICON_NAMES'; + +/** `lucide-react/dynamic`, `lucide-react/dynamic.mjs`, `…/dynamic.js`. */ +export const isDynamicEntrySpecifier = (specifier) => specifier.startsWith('lucide-react/dynamic'); + +/** + * Modules allowed to reach `lucide-react/dynamic*` through a STATIC import. + * + * ⛔ Empty, and that is the assertion (objectui#9204). lucide derives + * `iconNames` as `Object.keys(dynamicIconImports)`, so a static import of + * EITHER export puts the 1,767-entry dynamic-import map in the importer's chunk + * — 263,547 B rendered in the console's eager `ui-components` chunk, measured on + * the emitted artifact. Four modules imported it that way and the map rode every + * page load; the names now ship as data and the map loads through `import()` on + * the first icon that renders. + * + * Nothing else in the tree goes red when that regresses: the laziness is in the + * source, the cost is in a bundle, and the eager-closure budget only reports the + * total. This list is where the two meet — a static import here is named on the + * commit that adds it, rather than a kilobyte reading on a ceiling weeks later. + */ +export const DECLARED_EAGER_DYNAMIC_IMPORTERS = []; + /** * A module that builds its OWN `icons` object and indexes it is not a lucide * resolver. `plugin-chatbot/src/elements/tool.tsx` does exactly that, which @@ -787,25 +835,54 @@ function objectProp(objectLiteral, name) { export function discoverResolvers(root, files) { const record = []; const dynamic = []; + const eagerDynamic = []; for (const file of files) { if (isTestPath(file)) continue; const text = readFileSync(join(root, file), 'utf8'); - if (!text.includes('lucide-react')) continue; + // Both spellings of the dynamic surface have to survive this prefilter: a + // module that reads the vocabulary ONLY through the published catalogue + // need not mention `lucide-react` at all. + if (!text.includes('lucide-react') && !text.includes(DYNAMIC_CATALOGUE_BINDING)) continue; const sf = parseSource(root, file); let recordLocal = null; let readsDynamic = false; + let importsDynamicStatically = false; sf.forEachChild((node) => { if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return; const specifier = node.moduleSpecifier.text; - if (specifier.startsWith('lucide-react/dynamic')) readsDynamic = true; - if (specifier !== 'lucide-react') return; + if (isDynamicEntrySpecifier(specifier)) { + readsDynamic = true; + importsDynamicStatically = true; + } const bindings = node.importClause?.namedBindings; if (!bindings || !ts.isNamedImports(bindings)) return; for (const element of bindings.elements) { - if ((element.propertyName ?? element.name).text === 'icons') recordLocal = element.name.text; + const imported = (element.propertyName ?? element.name).text; + // The catalogue IS the dynamic vocabulary, so importing it is reading + // that surface — by binding rather than by specifier, because the same + // names arrive over three spellings (a relative path inside + // `packages/components`, the package entry, a deep path from a test). + if (imported === DYNAMIC_CATALOGUE_BINDING) readsDynamic = true; + if (specifier === 'lucide-react' && imported === 'icons') recordLocal = element.name.text; } }); + // `import('lucide-react/dynamic.mjs')` — the DEFERRED spelling, invisible to + // the import-declaration walk above and the whole point of objectui#9204. + // A census that could not see it would report the map's one remaining + // reader as having stopped reading the surface entirely. + const visitCalls = (node) => { + if ( + ts.isCallExpression(node) + && node.expression.kind === ts.SyntaxKind.ImportKeyword + && node.arguments.length > 0 + && ts.isStringLiteralLike(node.arguments[0]) + && isDynamicEntrySpecifier(node.arguments[0].text) + ) readsDynamic = true; + ts.forEachChild(node, visitCalls); + }; + ts.forEachChild(sf, visitCalls); if (readsDynamic) dynamic.push(file); + if (importsDynamicStatically) eagerDynamic.push(file); if (!recordLocal) continue; let indexes = false; const visit = (node) => { @@ -818,7 +895,7 @@ export function discoverResolvers(root, files) { ts.forEachChild(sf, visit); if (indexes) record.push(file); } - return { record: record.sort(), dynamic: dynamic.sort() }; + return { record: record.sort(), dynamic: dynamic.sort(), eagerDynamic: eagerDynamic.sort() }; } // ── Part 2: authored nodes ─────────────────────────────────────────────────── @@ -1051,6 +1128,7 @@ export function analyze(root, { anchors = ANCHORED_MAPS, declaredRecordReaders = DECLARED_RECORD_READERS, declaredDynamicReaders = DECLARED_DYNAMIC_READERS, + declaredEagerDynamicImporters = DECLARED_EAGER_DYNAMIC_IMPORTERS, negativeControl = DISCOVERY_NEGATIVE_CONTROL, recordReadingTypes = RECORD_READING_TYPES, } = {}) { @@ -1075,6 +1153,17 @@ export function analyze(root, { censusDiff('dynamic-surface resolver', discovered.dynamic, declaredDynamicReaders, 'It resolves names through `lucide-react/dynamic.mjs`, which still carries retired spellings — a second, more forgiving vocabulary.'); + for (const file of discovered.eagerDynamic) { + if (declaredEagerDynamicImporters.includes(file)) continue; + errors.push( + `EAGER \`lucide-react/dynamic\` import: ${file}\n` + + ' lucide derives `iconNames` from `dynamicIconImports`, so a STATIC import of either name puts the\n' + + ' 1,767-entry dynamic-import map in this module\'s chunk — 263,547 B rendered on the console\'s eager\n' + + ' path (objectui#9204). Read the names from `LUCIDE_ICON_NAMES` (@object-ui/components) and reach the\n' + + ' map through `import(\'lucide-react/dynamic.mjs\')`, the way `packages/components/src/lib/lazy-icon.tsx` does.', + ); + } + if (discovered.record.length === 0) { errors.push('discovery found NO record-reading resolver at all — it is not matching imports any more, and every "no violations" below is vacuous.'); } @@ -1115,6 +1204,8 @@ if (invokedDirectly) { for (const file of discovered.record) console.log(` ${file}`); console.log(`dynamic-surface resolvers discovered (${discovered.dynamic.length}), NOT judged here:`); for (const file of discovered.dynamic) console.log(` ${file}`); + console.log(`modules importing \`lucide-react/dynamic*\` STATICALLY (${discovered.eagerDynamic.length}; every one puts the import map on the eager path):`); + for (const file of discovered.eagerDynamic) console.log(` ${file}`); console.log(`authored icon names judged: ${counters.authoredJudged} (${counters.authoredDescendantJudged} of them on UNTYPED child items of a declared container) | icon names on nodes this gate declines to judge: ${counters.authoredDeclined}`); console.log(`anchored map entries judged: ${counters.anchoredJudged}`); console.log(''); diff --git a/scripts/gen-lucide-icon-names.mjs b/scripts/gen-lucide-icon-names.mjs new file mode 100644 index 0000000000..5dbc4d053b --- /dev/null +++ b/scripts/gen-lucide-icon-names.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Regenerate `packages/components/src/lib/lucide-icon-names.ts` — the eager + * mirror of lucide's DYNAMIC icon vocabulary. + * + * node scripts/gen-lucide-icon-names.mjs (also `pnpm gen:lucide-icon-names`) + * + * ## Why a mirror exists at all (objectui#9204) + * + * `iconNames` is `Object.keys(dynamicIconImports)` — lucide derives it FROM the + * 1,767-entry dynamic-import map, so importing the names imports the map, and + * the map is 263,547 B rendered in the console's eager `ui-components` chunk. + * `getLazyIcon`/`isLucideIconName` need only the membership answer, and they + * need it SYNCHRONOUSLY (`notificationIcon` picks between the authored icon and + * the severity glyph during render). So the names ship as data and the map — + * the part that is only ever CALLED, and only after a name has already been + * accepted — moves behind an `import()`. + * + * ## Why it cannot age silently + * + * `scripts/check-lucide-icon-record-names.mjs` states the principle this file + * answers to: "a hand-kept vocabulary is the same defect one level up — it ages + * the moment lucide retires the next name, and it ages SILENTLY." Nothing here + * is hand-kept. The names come from the installed lucide, and + * `packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts` + * re-derives them from that same install on every CI run and fails on any + * drift, naming this script as the repair. + * + * ⛔ The output is generated. Edit lucide's version in `package.json` and rerun + * this; never hand-edit the catalogue. + */ + +import { createRequire } from 'node:module'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +export const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +/** Where the catalogue lives, repo-relative. */ +export const CATALOGUE_PATH = 'packages/components/src/lib/lucide-icon-names.ts'; + +/** + * The package that owns the lucide dependency. Resolving through it — rather + * than from the repo root, where `lucide-react` is not resolvable — is the same + * choice `check-lucide-icon-record-names.mjs` makes and for the same reason: + * the generator must read the very copy `lazy-icon.tsx` renders from. + */ +export const LUCIDE_OWNER_PKG = 'packages/components/package.json'; + +/** `{ names, version }` of the installed lucide's DYNAMIC vocabulary. */ +export async function loadInstalledIconNames(root = REPO_ROOT) { + const lucideRequire = createRequire(join(root, LUCIDE_OWNER_PKG)); + const { iconNames } = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); + const version = JSON.parse(readFileSync(lucideRequire.resolve('lucide-react/package.json'), 'utf8')).version; + return { names: iconNames, version }; +} + +/** + * The catalogue's exact text, from a name list. + * + * One name per line inside a single template literal: a diff then shows the + * names that moved rather than one re-wrapped line, and the emitted module is + * the names plus one `split` instead of 2,025 quoted-and-comma'd elements. + * + * ⛔ The lucide VERSION is deliberately absent from the file. It would make + * every lucide bump a two-line diff that reads as a real change, and the + * version is not what the mirror is judged against — the installed vocabulary + * is, by the test named in the header below. + */ +export function renderCatalogue(names) { + return `/** + * 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's DYNAMIC icon vocabulary, as data (objectui#9204). + * + * ⛔ GENERATED — do not edit by hand. Run \`pnpm gen:lucide-icon-names\`. + * + * Every name lucide's \`lucide-react/dynamic.mjs\` can resolve. It is a strict + * SUPERSET of the runtime \`icons\` record: it still carries retired spellings + * (\`edit\`, \`smile\`, \`filter\`, \`alert-triangle\`), which is why + * \`scripts/check-lucide-icon-record-names.mjs\` judges only the record-reading + * resolver and censuses this surface separately. + * + * ## Why this is a mirror and not an import + * + * lucide derives \`iconNames\` as \`Object.keys(dynamicIconImports)\`, so + * \`import { iconNames } from 'lucide-react/dynamic.mjs'\` drags the whole + * 1,767-entry dynamic-import map into whatever chunk holds the importer — + * measured at 263,547 B rendered / 45,749 B gzipped of the console's eager + * \`ui-components\` chunk. The membership answer is needed synchronously + * (\`notificationIcon\` chooses between the authored icon and the severity glyph + * during render); the map is needed only AFTER a name has been accepted, and + * \`lazy-icon.tsx\` reaches it through \`import()\` for that. + * + * ## Why it cannot age silently + * + * \`../__tests__/lucide-icon-names-mirror-9204.test.ts\` re-derives this list + * from the installed lucide on every run and fails on any drift. The names are + * data here, never a second opinion about what lucide ships. + */ +export const LUCIDE_ICON_NAMES: readonly string[] = \`${names.join('\n')}\`.split('\\n'); +`; +} + +if (isEntrypoint(import.meta.url)) { + const { names, version } = await loadInstalledIconNames(); + const target = join(REPO_ROOT, CATALOGUE_PATH); + writeFileSync(target, renderCatalogue(names)); + console.log(`wrote ${CATALOGUE_PATH} — ${names.length} names from lucide-react ${version}`); +} From 76347342b3eb1b5ace310b72b15cfef37f963b04 Mon Sep 17 00:00:00 2001 From: os-dev Date: Sat, 12 Sep 2026 04:45:06 +0000 Subject: [PATCH 2/2] docs(components): correct every byte claim to the figures measured on 91facaef6 The card's -45,749 B ablation does not reproduce. Three console builds in one container put the map at 8,253 B gzipped of the eager `ui-components` chunk and the icon-name catalogue that has to replace it at 9,176 B in the same chunk, so deferring the map while `isLucideIconName` stays a synchronous exact-membership predicate is net +923 B. Say so in the files that carry the claim, and add the changeset the diff owes. Part of #9204 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9204-lucide-dynamic-map-deferred.md | 27 +++++++++++++++++++ apps/console/src/utils/getIcon.ts | 6 ++--- packages/app-shell/src/utils/getIcon.ts | 4 +-- .../src/views/metadata-admin/widgets.tsx | 2 +- .../lucide-icon-names-mirror-9204.test.ts | 4 +-- packages/components/src/index.ts | 2 +- packages/components/src/lib/lazy-icon.tsx | 11 ++++++-- .../components/src/lib/lucide-icon-names.ts | 15 ++++++----- .../check-lucide-icon-record-names.test.ts | 4 +-- scripts/check-lucide-icon-record-names.mjs | 16 +++++------ scripts/gen-lucide-icon-names.mjs | 25 +++++++++++------ 11 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 .changeset/9204-lucide-dynamic-map-deferred.md diff --git a/.changeset/9204-lucide-dynamic-map-deferred.md b/.changeset/9204-lucide-dynamic-map-deferred.md new file mode 100644 index 0000000000..ff34a0c660 --- /dev/null +++ b/.changeset/9204-lucide-dynamic-map-deferred.md @@ -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. diff --git a/apps/console/src/utils/getIcon.ts b/apps/console/src/utils/getIcon.ts index d39db84b55..4ccb4e82fa 100644 --- a/apps/console/src/utils/getIcon.ts +++ b/apps/console/src/utils/getIcon.ts @@ -9,9 +9,9 @@ * 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 1,767-entry dynamic-import map on the console's eager path, which is - * the cost this card removes; the shared resolver keeps the icon NAMES as data - * and fetches the map through `import()` on first use. + * 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 diff --git a/packages/app-shell/src/utils/getIcon.ts b/packages/app-shell/src/utils/getIcon.ts index 830df9525f..dc8fd054e0 100644 --- a/packages/app-shell/src/utils/getIcon.ts +++ b/packages/app-shell/src/utils/getIcon.ts @@ -22,8 +22,8 @@ * for two reasons that are the same reason: * * - the membership Set was built from `iconNames`, and lucide derives that - * from its 1,767-entry dynamic-import map — so this module's import alone - * put 263,547 B of rendered map on the console's eager path; + * 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. diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 09ce39c401..fb1edc09ff 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -1556,7 +1556,7 @@ function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledB // `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 1,767-entry map with them, onto the eager path (objectui#9204). +// 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 = new Set(LUCIDE_ICON_NAMES); // Cap the rendered grid — each cell mounts a lazily-loaded icon, so showing all diff --git a/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts b/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts index 54a4994950..d1800de7da 100644 --- a/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts +++ b/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts @@ -13,8 +13,8 @@ * `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 - * 1,767-entry dynamic-import map, which is what put 263,547 B of rendered map on - * the console's eager path. + * 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 diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 6d650f98d3..eabe76fb3f 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -43,7 +43,7 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/ // 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 -// 1,767-entry dynamic-import map onto the eager path (objectui#9204). +// 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 diff --git a/packages/components/src/lib/lazy-icon.tsx b/packages/components/src/lib/lazy-icon.tsx index c09b74565b..0cf85366dd 100644 --- a/packages/components/src/lib/lazy-icon.tsx +++ b/packages/components/src/lib/lazy-icon.tsx @@ -22,9 +22,16 @@ * * That entry hands out two things this file needs, and lucide derives one from * the other: `iconNames` is `Object.keys(dynamicIconImports)`. So a static - * import of EITHER name drags the 1,767-entry dynamic-import map into whatever + * import of EITHER name drags the 2,025-entry dynamic-import map into whatever * chunk holds this module — the console's eager `ui-components` chunk, where it - * was measured at 263,547 B rendered (objectui#9204). + * costs 8,253 B gzipped (measured, objectui#9204). + * + * ⚠️ Deferring it does NOT bank those 8,253 B, and the number below is why this + * file is not the whole fix. The map's KEYS are the names, so they have to ship + * anyway, and a bare list of them costs 9,176 B gzipped in that same chunk — + * more than the map that carried them. The saving arrives only when the NAMES + * can leave too, which is a question about `isLucideIconName`'s contract, not + * about this import. * * The two halves are needed at different times: * diff --git a/packages/components/src/lib/lucide-icon-names.ts b/packages/components/src/lib/lucide-icon-names.ts index 68ed35da4f..6c8fbfb890 100644 --- a/packages/components/src/lib/lucide-icon-names.ts +++ b/packages/components/src/lib/lucide-icon-names.ts @@ -21,12 +21,15 @@ * * lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so * `import { iconNames } from 'lucide-react/dynamic.mjs'` drags the whole - * 1,767-entry dynamic-import map into whatever chunk holds the importer — - * measured at 263,547 B rendered / 45,749 B gzipped of the console's eager - * `ui-components` chunk. The membership answer is needed synchronously - * (`notificationIcon` chooses between the authored icon and the severity glyph - * during render); the map is needed only AFTER a name has been accepted, and - * `lazy-icon.tsx` reaches it through `import()` for that. + * 2,025-entry dynamic-import map into whatever chunk holds the importer — + * measured at 8,253 B gzipped of the console's eager `ui-components` chunk. The + * membership answer is needed synchronously (`notificationIcon` chooses between + * the authored icon and the severity glyph during render); the map is needed + * only AFTER a name has been accepted, and `lazy-icon.tsx` reaches it through + * `import()` for that. + * + * ⚠️ This list is not free: it costs 9,176 B gzipped in that same chunk, MORE + * than the map whose keys these names were. See `gen-lucide-icon-names.mjs`. * * ## Why it cannot age silently * diff --git a/scripts/__tests__/check-lucide-icon-record-names.test.ts b/scripts/__tests__/check-lucide-icon-record-names.test.ts index d1f9a2c97a..e464c3a5f5 100644 --- a/scripts/__tests__/check-lucide-icon-record-names.test.ts +++ b/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -786,9 +786,9 @@ describe('the surface census is re-derived on every run', () => { expect(result.discovered.eagerDynamic).toEqual([]); }); - it('fails on a STATIC import of the dynamic entry — that is the 263 KB map', () => { + it('fails on a STATIC import of the dynamic entry — that is the whole map', () => { // lucide derives `iconNames` from `dynamicIconImports`, so this import puts - // the 1,767-entry map in the importer's chunk. Nothing else in the tree + // the 2,025-entry map in the importer's chunk. Nothing else in the tree // reddens: the laziness is in the source and the cost is in a bundle. const result = judge('eager-dynamic', { files: { diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 8b073640cc..0b03eee351 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -43,8 +43,8 @@ * * ── HOW a site reaches DYNAMIC is also censused (objectui#9204) ───────────── * lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so importing - * the names imports the 1,767-entry dynamic-import map with them. Four modules - * did, and the map — 263,547 B rendered — sat in the console's eager + * the names imports the 2,025-entry dynamic-import map with them. Four modules + * did, and the map — 8,253 B gzipped, measured — sat in the console's eager * `ui-components` chunk on every page load. Two of those were transcriptions of * `getLazyIcon` and are now delegations; the surviving pair reads the names from * `LUCIDE_ICON_NAMES`, a generated mirror of that same vocabulary, and reaches @@ -339,11 +339,11 @@ export const isDynamicEntrySpecifier = (specifier) => specifier.startsWith('luci * * ⛔ Empty, and that is the assertion (objectui#9204). lucide derives * `iconNames` as `Object.keys(dynamicIconImports)`, so a static import of - * EITHER export puts the 1,767-entry dynamic-import map in the importer's chunk - * — 263,547 B rendered in the console's eager `ui-components` chunk, measured on - * the emitted artifact. Four modules imported it that way and the map rode every - * page load; the names now ship as data and the map loads through `import()` on - * the first icon that renders. + * EITHER export puts the 2,025-entry dynamic-import map in the importer's chunk + * — 8,253 B gzipped of the console's eager `ui-components` chunk, measured on + * the emitted artifact across three builds. Four modules imported it that way; + * the names now ship as data and the map loads through `import()` on the first + * icon that renders. * * Nothing else in the tree goes red when that regresses: the laziness is in the * source, the cost is in a bundle, and the eager-closure budget only reports the @@ -1158,7 +1158,7 @@ export function analyze(root, { errors.push( `EAGER \`lucide-react/dynamic\` import: ${file}\n` + ' lucide derives `iconNames` from `dynamicIconImports`, so a STATIC import of either name puts the\n' - + ' 1,767-entry dynamic-import map in this module\'s chunk — 263,547 B rendered on the console\'s eager\n' + + ' 2,025-entry dynamic-import map in this module\'s chunk — 8,253 B gzipped on the console\'s eager\n' + ' path (objectui#9204). Read the names from `LUCIDE_ICON_NAMES` (@object-ui/components) and reach the\n' + ' map through `import(\'lucide-react/dynamic.mjs\')`, the way `packages/components/src/lib/lazy-icon.tsx` does.', ); diff --git a/scripts/gen-lucide-icon-names.mjs b/scripts/gen-lucide-icon-names.mjs index 5dbc4d053b..cdd8dd73ed 100644 --- a/scripts/gen-lucide-icon-names.mjs +++ b/scripts/gen-lucide-icon-names.mjs @@ -10,14 +10,20 @@ * ## Why a mirror exists at all (objectui#9204) * * `iconNames` is `Object.keys(dynamicIconImports)` — lucide derives it FROM the - * 1,767-entry dynamic-import map, so importing the names imports the map, and - * the map is 263,547 B rendered in the console's eager `ui-components` chunk. + * 2,025-entry dynamic-import map, so importing the names imports the map. * `getLazyIcon`/`isLucideIconName` need only the membership answer, and they * need it SYNCHRONOUSLY (`notificationIcon` picks between the authored icon and * the severity glyph during render). So the names ship as data and the map — * the part that is only ever CALLED, and only after a name has already been * accepted — moves behind an `import()`. * + * ⚠️ Measured on `91facaef6`, and the measurement is why this file is only half + * a fix: the map costs 8,253 B gzipped of the eager `ui-components` chunk, and + * this catalogue — the same names, without the map — costs 9,176 B in the same + * chunk. Deferring the map while keeping the names eager is net +923 B. The + * names are the cost; lucide's map is a cheaper container for them than a list + * is. See the PR for the three builds. + * * ## Why it cannot age silently * * `scripts/check-lucide-icon-record-names.mjs` states the principle this file @@ -96,12 +102,15 @@ export function renderCatalogue(names) { * * lucide derives \`iconNames\` as \`Object.keys(dynamicIconImports)\`, so * \`import { iconNames } from 'lucide-react/dynamic.mjs'\` drags the whole - * 1,767-entry dynamic-import map into whatever chunk holds the importer — - * measured at 263,547 B rendered / 45,749 B gzipped of the console's eager - * \`ui-components\` chunk. The membership answer is needed synchronously - * (\`notificationIcon\` chooses between the authored icon and the severity glyph - * during render); the map is needed only AFTER a name has been accepted, and - * \`lazy-icon.tsx\` reaches it through \`import()\` for that. + * 2,025-entry dynamic-import map into whatever chunk holds the importer — + * measured at 8,253 B gzipped of the console's eager \`ui-components\` chunk. The + * membership answer is needed synchronously (\`notificationIcon\` chooses between + * the authored icon and the severity glyph during render); the map is needed + * only AFTER a name has been accepted, and \`lazy-icon.tsx\` reaches it through + * \`import()\` for that. + * + * ⚠️ This list is not free: it costs 9,176 B gzipped in that same chunk, MORE + * than the map whose keys these names were. See \`gen-lucide-icon-names.mjs\`. * * ## Why it cannot age silently *